Go From Scratch (Beginner)¶
Beginner path: from never-coded to reading and contributing to real OSS Go.
Printing this page
Use your browser's Print → Save as PDF. The print stylesheet hides navigation, comments, and other site chrome; pages break cleanly at section boundaries; advanced content stays included regardless of beginner-mode state.
Go From Scratch - Beginner to OSS Contributor¶
This path takes you from "I have never written code" to "I can clone a real Go project, read most of it, and submit a pull request for a small fix." It is unhurried, honest, and assumes nothing.
Who this is for¶
- You have never written code, OR
- You have copy-pasted code from tutorials but couldn't explain it line by line.
That's it. No "you should already know X." If you need to know something, this path will teach it.
What you'll need¶
- A computer (any OS - macOS, Linux, Windows all work).
- A text editor. VS Code is a good free default. Notepad/TextEdit work too, just less comfortable.
- A terminal. Built into every operating system.
- About 5 hours per week. The path is sized for ~4-6 months at that pace. Twice the hours, half the time. Half the hours, double the time. The path doesn't expire.
Why Go (and not some other language)¶
A few reasons that matter when you're starting:
- The language is small. You can fit most of Go's syntax in your head. Other popular languages have more features than any person uses.
- Errors are blunt. Go's compiler tells you exactly what's wrong and which line. You'll spend less time confused.
- It's used in real things. Docker, Kubernetes, Terraform, much of cloud infrastructure is Go. The OSS projects you'll graduate to contributing to are real and important.
- You'll be productive in weeks, not months. People who pick Go as their first language tend to ship something useful within a month.
How this path works¶
Each page does one thing:
- Says what you'll learn this session.
- Shows you a small program.
- Walks through the code line by line.
- Gives you a tiny exercise.
Do the exercises. Reading without doing won't stick - I promise. Type the code yourself; don't copy-paste. Your fingers learning Go is part of how your brain learns Go.
The deal¶
- I will not pretend things are easy when they aren't. When something is confusing the first time, I'll say so.
- I will not send you to "consult the primary source" or "read the standard library." If you need to know it, this path will teach it.
- There are no stupid questions, only stupid skipped exercises.
- The goal at the end is real: a pull request to a real open-source project. We will work up to it.
The pages¶
The path is 16 pages, sized so you can do one or two per week.
| # | Title | What you'll know after |
|---|---|---|
| 00 | Introduction | What we're doing and why |
| 01 | Setup | Go installed; "hello, world" printed |
| 02 | First real program | Variables, numbers, text |
| 03 | Decisions and loops | if, for |
| 04 | Functions | Named reusable blocks |
| 05 | Making your own types | struct, methods |
| 06 | Many things at once | Slices, maps |
| 07 | Errors | Go's way of handling failures |
| 08 | Pointers and memory | Gentle introduction |
| 09 | Concurrency 101 | Goroutines and channels |
| 10 | Tests | Writing your first test |
| 11 | Packages and modules | Using code other people wrote |
| 12 | Reading other people's code | The bridge |
| 13 | Picking a project | What "manageable" looks like |
| 14 | Anatomy of a small OSS repo | Case study |
| 15 | Your first contribution | Workflow + PR |
Start with Introduction.
00 - Introduction¶
What this session is¶
A 10-minute read. No code yet. The point is to set expectations honestly so you can decide if this path is for you.
What you're going to build, eventually¶
Programming is not a thing you watch - it's a thing you do. By the end of this path, you'll have done all of these:
- Written and run small programs that print, calculate, and make decisions.
- Built a little command-line tool that takes input and produces output.
- Written tests for your own code and watched them pass and fail.
- Cloned an open-source project off the internet, browsed its code, run its tests, and understood roughly what it does.
- Submitted a small fix to one of those open-source projects as a real pull request.
That last point is the goal. Everything else is preparation.
The deal we're making¶
A few things you should know about how this path works:
It's slow on purpose. Most beginner programming tutorials drop you into "build a real app" by page three. That works for some people. For most, it leaves them able to copy code without understanding it. This path is the opposite: one concept per page, with time to actually internalize each one.
It assumes nothing. If a word appears that you haven't seen, it'll be defined right there. No glossary lookups. No "see chapter 12."
It does the work where the work is. Some pages are short because the concept is small. Some are long because the concept is hard. We don't pad.
You have to type the code. Reading code without typing it has roughly the same effect as reading sheet music without playing it. Type every example, even when you "get it" from reading.
You will be confused. Often. Especially in the first month. That's normal. Programming is unusual in how often you feel stuck - the trick is not to panic when it happens. Re-read the page. Run the code. Change one thing and see what changes. Confusion is not a sign you're bad at this; it's a sign you're doing it.
What you need to start¶
- A computer. Any operating system works.
- A text editor. VS Code is free, multi-platform, and what I'd suggest unless you already love something else.
- A terminal. (On macOS it's the Terminal app. On Windows it's PowerShell or the Windows Terminal app. On Linux you already know.)
- ~5 hours per week. Less is fine; the path just takes longer.
- A specific notebook or text file where you can write down questions as they come up. You'll have lots. Writing them down lets you keep going past them; when you come back you can answer them with what you've since learned.
What you do NOT need¶
- Math beyond basic arithmetic. Programming uses arithmetic; it's not "advanced math."
- A computer-science degree, or any plan to get one.
- A "gift" for computers. There is no such thing. People who seem to "just get it" have spent more hours doing it than you have. That's all.
- To know any other programming language first. Go is a fine first language. Maybe a better first language than the usual recommendations.
How long this realistically takes¶
The honest answer: 4 to 6 months at 5 focused hours per week, to get to the "submit a pull request" goal.
If you have less time, take longer. If you have more time, take less. The path doesn't expire and the pages don't go stale.
I cannot make the time go faster. Nobody can. The thing that takes weeks is not "absorbing information" - it's your brain getting used to a new way of thinking. That happens at biology speed, not internet speed.
What success looks like at the end¶
You'll be able to:
- Open a Go file you've never seen and read it like a recipe - knowing what each piece does.
- Open a project on GitHub written in Go and tell me, in two paragraphs, what it does and how.
- Find a small bug or missing feature in such a project and fix it.
- Submit that fix as a pull request that follows the project's conventions.
You will not be able to:
- Build a new operating system. (Nobody can in 6 months.)
- Win competitive programming contests. (Different skill, mostly orthogonal.)
- Tell people you're a "senior engineer." (That takes years of doing the work after this path ends.)
What you will have: the foundation to keep going. Where you go from here is up to you.
One last thing before we start¶
If at any point a page feels too dense, stop and re-read it. If you re-read it and it's still too dense, that's a bug in the page - note it, skip forward, and come back. The path is alive; it gets fixed when readers say "this part lost me."
Ready? Next: Setup →
01 - Setup¶
What this session is¶
About 30 minutes. By the end you'll have Go installed, a place to put your code, and one tiny program that prints "hello, world" - the traditional first thing.
Step 1: Install Go¶
Go to go.dev/dl. Download the installer for your operating system.
- macOS: download the
.pkgfile. Double-click; follow the prompts. - Windows: download the
.msifile. Double-click; follow the prompts. - Linux: either download the
.tar.gzfrom go.dev/dl and follow the instructions there, or use your distro's package manager:sudo apt install golang-goon Debian/Ubuntu,sudo dnf install golangon Fedora.
Once it's done, open a terminal.
- macOS: press
⌘ Space, type "terminal", hit enter. - Windows: press
Windows, type "powershell", hit enter. - Linux: you know how.
In the terminal, type:
You should see something like:
The version number and the bit after will be different - that's fine. As long as you see "go version" and a number, Go is installed.
If you get command not found or something similar, the install didn't work. On macOS, try opening a new terminal window (the path might not have updated in the old one). On Linux, the package manager version may be old; download from go.dev/dl directly instead.
Step 2: Pick a folder for your code¶
You're going to write a lot of small programs. They need somewhere to live.
Pick anywhere on your computer you can find again. I'll use ~/code/go-learning/ in my examples. (The ~ means your home folder. On macOS that's /Users/yourname; on Linux it's /home/yourname; on Windows it's C:\Users\yourname.)
In your terminal, create the folder and go into it:
Then check you're there:
pwd means "print working directory" - it shows where you are. You should see your new folder's path.
Step 3: The smallest possible Go program¶
Open your text editor (VS Code, or whatever you chose). Create a new file. Save it as hello.go inside the go-learning folder you just made.
Type this into the file - type it, don't copy-paste:
Save the file.
Step 4: Run it¶
Back in your terminal, in the same folder as hello.go, type:
You should see:
That's your first program. Take a moment.
What just happened - line by line¶
You typed five lines (plus a blank one). Each one matters:
-
package main- Every Go file says which "package" it belongs to.mainis special: it's the package that becomes a runnable program. We'll come back to packages later; for now, just remember every program you write will start with this line. -
import "fmt"- Pulls in thefmtpackage (short for "format").fmthas functions for printing and formatting text. To use code that lives somewhere else, you have toimportit first. -
func main()- Defines a function calledmain. A "function" is a named block of code.mainis special: it's the function that runs when the program starts. Every Go program needs exactly onemainfunction in themainpackage. -
fmt.Println("hello, world")- Calls thePrintlnfunction (the "Pl" stands for "print line") from thefmtpackage, with"hello, world"as the input.Printlnprints whatever you give it, then moves to a new line. -
The curly braces
{and}mark the beginning and end of the function's body - the code that belongs tomain.
Don't try to memorize this. You're going to type package main, import, func main() so many times in the next few sessions that it'll become automatic, like signing your name.
Try changing things¶
The way to learn is to break things on purpose and see what happens. Try each of these:
-
Change
"hello, world"to your name. Run again. Did it work? -
Add a second line that prints something else:
-
Try printing a number - no quotes:
-
Now break it on purpose. Remove the closing
}and run. Read the error Go gives you. (Don't worry about understanding all of it - just notice that Go tells you which line is wrong.) -
Put the
}back. Now mistypePrintlnasprintln(lowercasep). Run. Read the error. Go is case-sensitive:Printlnandprintlnare different names.
Reading errors is most of programming. Get comfortable seeing them. They are not scary; they are the computer telling you what it noticed.
What you might wonder¶
"Why all this scaffolding for one print line?"
Fair question. In Python you'd write print("hello, world") and that's the whole file. Go trades typing for predictability: every Go program has the same shape, so you always know where to look. You'll appreciate this when programs get bigger.
"Do I need to compile it?"
go run does compiling AND running in one step - it builds your program in memory, runs it, throws the result away. Later you'll meet go build, which produces a separate executable file you can ship to other computers.
"What does the func mean?"
Short for "function." It's how Go marks "here begins a function definition." You'll write it a lot.
"Are tabs or spaces required for the indentation?"
Go uses tabs by convention. There's a tool called gofmt that reformats your code; we'll meet it soon. For now, your editor probably does the right thing if you press Tab.
Done¶
You have: - Go installed. - A folder to put your code in. - One working program.
This was the boring infrastructure step. Next page is where the real learning starts.
02 - First Real Program¶
What this session is¶
About 45 minutes. You'll learn three things: variables (storing data), types (kinds of data), and the expressions you can do with them. By the end you'll have written a program that uses all three.
Why variables exist¶
Programs do things with data. To do things with data, you have to store it somewhere named, so you can refer to it later.
That's all a "variable" is: a name attached to a piece of data.
A small program with variables¶
Open your go-learning folder. Create a new file called greet.go. Type this in:
package main
import "fmt"
func main() {
name := "Alice"
age := 30
fmt.Println(name, "is", age, "years old")
}
Run it:
You should see:
What's new here¶
Two lines you haven't seen before:
Let's unpack name := "Alice":
nameis the name of a new variable.:=is the "create and assign" operator. Read it as "is set to.""Alice"is the value we're putting into it. The double quotes mean it's text (a "string").
So name := "Alice" reads as: "create a variable called name and set it to the text Alice."
age := 30 does the same thing with a number. No quotes around 30 - numbers don't take quotes.
The last line:
You've seen fmt.Println before. New thing: it can take multiple things separated by commas, and it prints them with spaces in between. We give it four things - the value of name, the text "is", the value of age, and the text "years old". Out comes one line with all four glued together by spaces.
Types: what kind of thing is this?¶
Every value in Go has a type. The type tells Go what kind of thing it is - text? a number? something else? - and what you can do with it.
You'll meet many types over time. The first three you need to know are:
| Type | What it holds | Example values |
|---|---|---|
int |
whole numbers (positive, negative, or zero) | 0, 42, -7, 1000 |
string |
text in double quotes | "hello", "", "a long sentence" |
bool |
one of two values: true or false | true, false |
Notice you didn't have to tell Go that name is a string and age is an int. Go figured it out from the values you gave them - "Alice" has quotes (must be a string), 30 doesn't (must be a number). This is called type inference.
There's a longer way to write the same thing, where you spell out the type:
Both forms do the same thing. Use := when you can (it's shorter); save var for cases we'll meet later. But you'll see var in real code, so don't be surprised.
What you can do with numbers¶
The usual arithmetic works:
x := 10
y := 3
fmt.Println(x + y) // 13
fmt.Println(x - y) // 7
fmt.Println(x * y) // 30
fmt.Println(x / y) // 3
fmt.Println(x % y) // 1
That // 13 part is a comment - anything after // on a line is ignored by Go. Comments are how you leave notes for yourself (or future readers) in the code.
Three things to notice:
x / ygave3, not3.333.... That's becausexandyareints - integers, whole numbers. Integer division throws away the remainder. To get the decimal answer you need a different type (float64), which we'll meet when we actually need it.x % ygave1. The%operator gives the remainder after division.10 / 3is3with1left over, so10 % 3is1. We'll use this often to test "is this number even?" (n % 2 == 0is true ifnis even).- Both operands have to be the same type. You can't
x + "hello".
What you can do with strings¶
You can stick two strings together with +:
greeting := "hello"
name := "world"
message := greeting + ", " + name
fmt.Println(message) // hello, world
The technical word for "stick two strings together" is concatenate. You'll hear it.
Notice the same symbol + does two different jobs:
- Between numbers: addition.
- Between strings: concatenation.
This is normal in programming languages. Go uses the type to decide which job to do.
What you cannot do is mix:
Try it. Go will refuse to compile. The error will say something like "invalid operation: s + n (mismatched types string and int)". The fix: turn the number into a string first.
The most-used tool for this is fmt.Sprintf. It's like Println but instead of printing, it builds a string and gives it back to you:
The %d is a placeholder that means "put a number here." fmt.Sprintf looks at the first argument (the template) and fills in %d with the value of n.
You'll meet other placeholders:
- %d - for numbers.
- %s - for strings.
- %v - for "any value, use the default representation."
You don't need to memorize them all. When you can't remember, write %v and it'll usually do the right thing.
What you can do with booleans¶
A bool is just true or false. You'll use them in decisions (next page). For now:
Exercise¶
Type this in a new file called me.go:
Write a program that:
- Has a variable for your name (a string).
- Has a variable for your favorite number (an int).
- Has a variable for whether it's morning right now (a bool).
- Prints a line like:
"Hi, I'm Victor, my favorite number is 7, and yes (true) it's morning."
Try it two ways:
- First with multiple arguments to fmt.Println (fmt.Println("Hi, I'm", name, ...)).
- Then with fmt.Sprintf and %s, %d, %v placeholders, building one big string and printing it once.
Don't skip this. The act of typing is the learning.
What you might wonder¶
"Why does integer division throw away the remainder?"
Because integers can't represent fractional values. 10 / 3 has to give some integer answer - Go picks the closest one that fits, which is 3. If you want 3.333..., you need decimals, which is a different type (float64). We'll meet floats when we need them.
"Why does Go yell at me for mixing strings and numbers?" By design. In some languages, adding a number to a string silently converts the number ("items: " + 5 becomes "items: 5"). That seems nice until you have a bug where you accidentally concatenated when you meant to add. Go refuses on purpose; you have to be explicit about what you want.
"What happens if I never use a variable I declared?" Go won't compile. This is on purpose too - unused variables are usually bugs (you typed the wrong name somewhere). Either use the variable, or delete the line.
Done¶
You can now:
- Make variables and give them values.
- Tell apart the three basic types: int, string, bool.
- Do arithmetic on numbers.
- Stick strings together.
- Build a string with fmt.Sprintf and placeholders.
Next page: making your program decide and repeat.
03 - Decisions and Loops¶
What this session is¶
About an hour. You'll learn how to make your program decide between options (with if) and how to make it repeat something (with for). These two things are the building blocks of every program that does anything more than print a fixed message.
Decisions with if¶
The world's smallest decision:
package main
import "fmt"
func main() {
age := 18
if age >= 18 {
fmt.Println("adult")
} else {
fmt.Println("minor")
}
}
Run it. You'll see adult.
Now change age := 18 to age := 15 and run it again. You'll see minor.
What's happening:
if age >= 18 { ... }- run the code in the braces only ifage >= 18is true.else { ... }- if the condition was false, run this code instead.
The condition is whatever's between if and {. It must evaluate to a bool - true or false.
Comparison operators¶
The operators that produce true/false:
| Operator | Meaning |
|---|---|
== |
equal to |
!= |
not equal to |
< |
less than |
<= |
less than or equal to |
> |
greater than |
>= |
greater than or equal to |
A common mistake: writing = (one equals sign) when you mean == (two). = is assignment ("set this to that"); == is comparison ("does this equal that?"). Go will give a compile error if you mix them up; just notice it now.
Chaining decisions with else if¶
What if you have more than two cases?
score := 75
if score >= 90 {
fmt.Println("A")
} else if score >= 80 {
fmt.Println("B")
} else if score >= 70 {
fmt.Println("C")
} else {
fmt.Println("F")
}
Reads top to bottom. The first condition that's true wins; everything else is skipped. If none match, the else block runs.
Combining conditions: &&, ||, !¶
You can build bigger conditions out of smaller ones:
| Operator | Meaning | Example |
|---|---|---|
&& |
and (both true) | age >= 18 && hasLicense |
\|\| |
or (at least one true) | isWeekend \|\| isHoliday |
! |
not (flip true to false) | !isReady |
Example:
The condition is true only when both halves are true.
Repetition: for¶
This is the part that takes a few tries to internalize. Read carefully.
Print the numbers 1 through 5:
That for line is doing three things, separated by semicolons:
i := 1- create a variableistarting at 1. (This happens once, before anything else.)i <= 5- the condition that keeps the loop going. Checked before each round.i++- what to do after each round. (i++is shorthand fori = i + 1.)
The flow is:
- Set
i = 1. Check1 <= 5. True → run the body. Print1. Then doi++→i = 2. - Check
2 <= 5. True → print2. Theni = 3. - ... continues ...
- Check
5 <= 5. True → print5. Theni = 6. - Check
6 <= 5. False → stop.
Output:
Type this in. Run it. Change 1 to 10 and <= 5 to <= 20. Change i++ to i = i + 2 and see what happens. The way to internalize loops is to mess with them.
Two shorter forms¶
The "keep going while X" form, when you don't have a counter:
This prints 10, 9, 8, ..., 1. There's no "init" or "after each round" part - just the condition. The body has to do whatever changes the condition, otherwise the loop never stops.
The "forever" form, when you'll stop from inside:
Useful in programs that wait for events, listen on a socket, etc. We'll see real uses later.
Breaking out early: break and continue¶
break stops the loop entirely.
continue skips to the next round, without running the rest of the body this time.
for i := 1; i <= 10; i++ {
if i == 5 {
break // stop the whole loop when i is 5
}
if i%2 == 0 {
continue // skip the print for even numbers
}
fmt.Println(i)
}
Output: 1, 3. Why?
- i=1: not 5, not even → print
1. - i=2: not 5, even →
continue(skip print). - i=3: not 5, not even → print
3. - i=4: even → skip.
- i=5:
break→ stop entirely.
Putting it together¶
A small program that classifies the numbers 1 to 10:
package main
import "fmt"
func main() {
for i := 1; i <= 10; i++ {
if i%2 == 0 {
fmt.Println(i, "even")
} else {
fmt.Println(i, "odd")
}
}
}
Type and run. Read the output. Read the code. Look at each line and ask: which line produced this output?
Exercise¶
Type this in a new file called classify.go:
Write a program that, for each number from 1 to 20:
- If the number is divisible by 3, print
Fizzinstead of the number. - If the number is divisible by 5, print
Buzzinstead. - If divisible by both 3 and 5, print
FizzBuzz. - Otherwise print the number.
(This is the classic "FizzBuzz" problem. It's famous as a small interview question, and a very good exercise for cementing if/else if/else with a loop.)
Hint: check the "both 3 and 5" case first. Why? Think about what would happen if you checked "divisible by 3" first.
When you finish, the output should be:
Don't move on until your program prints exactly this.
What you might wonder¶
"What about while loops?"
Other languages have a separate while keyword. Go doesn't - for condition { } does the same thing. One loop keyword, fewer things to remember.
"What about do-while?"
Use:
"Why doesn't Go have i++ as an expression?"
In some languages j = i++ is legal and confusing. In Go, i++ is a statement on its own line - it doesn't return a value. This rules out a category of bugs that show up in C and C++.
"Why braces on if even for one line?"
In some languages you can write if (cond) doThing(); without braces. Go requires the braces always. This is opinionated but on purpose - a category of bugs (forgetting braces, then adding a second line that doesn't actually belong to the if) becomes impossible.
Done¶
You can now:
- Make a program take different actions based on conditions (if, else if, else).
- Combine conditions with &&, ||, !.
- Repeat actions with for, in three forms.
- Exit a loop early with break, skip an iteration with continue.
You now have the basic shapes that every program is built from. Combined with what you learned in Page 02, you can in principle write any program - just very, very long ones.
The next page is the abstraction that lets your programs stay short as they get bigger: functions.
04 - Functions¶
What this session is¶
About an hour. You'll learn how to define your own functions, and why doing so changes everything about how you write programs.
The problem functions solve¶
So far every program you've written has been one block of code inside main(). That works for 5-line programs. It stops working around 30 lines, for three reasons:
- You can't see what the program does by looking at it. The structure is gone.
- You can't reuse anything. If you compute "the square of a number" in three places, you've typed
x * xthree times. - You can't test pieces. The whole program runs or doesn't run; you can't easily check one calculation in isolation.
A function solves all three. It's a named block of code that takes some input and (usually) gives you some output back.
The shape of a function¶
Concrete example:
package main
import "fmt"
func double(x int) int {
return x * 2
}
func main() {
fmt.Println(double(5)) // 10
fmt.Println(double(7)) // 14
}
Type it. Run it. You should see 10 and 14.
Walk through it slowly:
func double(x int) int {- defines a function calleddouble. It takes one input (called a parameter), namedx, of typeint. It returns anint. The body is between the curly braces.return x * 2- computesx * 2and sends that value back to whoever called the function.double(5)- calls the function with5as the value ofx. The function runs, returns10, andPrintlnprints it.
Notice the function double exists outside main. Functions go at the top level of the file, not nested inside main. (Other languages let you nest them; Go does not, in the basic form.)
Multiple parameters¶
A function can take more than one input:
Or, when several parameters share a type, you can collapse the declaration:
Both forms mean the same thing. The second is more idiomatic in Go.
Functions that don't return anything¶
Sometimes a function just does something - it doesn't produce a value to give back. In that case, leave out the return type:
sayHi doesn't return anything. There's no return line in its body. Its job is just to print.
The unusual thing: returning two values at once¶
In most languages, a function returns one thing. Go lets you return two or more things at once. This is uncommon enough to be worth its own section.
func divide(a, b int) (int, int) {
quotient := a / b
remainder := a % b
return quotient, remainder
}
func main() {
q, r := divide(17, 5)
fmt.Println("quotient:", q, "remainder:", r)
// quotient: 3 remainder: 2
}
Type and run.
The new things:
func divide(a, b int) (int, int) {- the return type is(int, int). The parentheses mean "two things, bothints."return quotient, remainder- return both, separated by a comma.q, r := divide(17, 5)- receive both, by writing two names separated by a comma on the left of:=.
This pattern is everywhere in Go. The reason: Go's whole story for handling errors (which you'll learn in a later page) uses it. Every function that can fail returns two things: the result it computed, and an error value telling you whether something went wrong. We'll get there.
For now, just internalize: in Go, "two return values" is normal.
Why functions are worth the trouble¶
Here's a small program that uses two functions:
package main
import "fmt"
func square(x int) int {
return x * x
}
func sumOfSquares(a, b int) int {
return square(a) + square(b)
}
func main() {
fmt.Println(sumOfSquares(3, 4)) // 9 + 16 = 25
}
Look at sumOfSquares. It calls square twice. Functions can call other functions. This is how programs get built up - small named pieces, composed.
Now imagine you write the same logic without functions:
Shorter, sure. But:
- The reader has to figure out what (a * a) + (b * b) means. sumOfSquares(3, 4) says it.
- If you need the squares in three different places, you re-type x * x three times.
- If you later decide squaring should clamp to a maximum (say, return 1000 if the result is bigger), you change one function. In the long version, you change every place.
These benefits compound. They're invisible at 30 lines and decisive at 300.
Variables inside vs outside¶
A variable declared inside a function exists only inside that function. The technical word is scope.
func double(x int) int {
result := x * 2
return result
}
func main() {
fmt.Println(result) // ERROR - `result` doesn't exist out here
}
Each function has its own world. Variables don't leak between them. The way to get information into a function is parameters; the way to get information out is the return value(s).
Exercise¶
In a new file iseven.go:
- Write a function
isEven(n int) boolthat returnstrueifnis even andfalseotherwise.
Hint: use the % operator from Page 02. n % 2 == 0 is a bool.
-
From
main, printisEven(4)andisEven(7). You should seetrueandfalse. -
Write a second function
countEvens(max int) intthat counts how many even numbers are in1, 2, 3, ..., max. It should use aforloop and call yourisEvenfunction for each. -
From
main, printcountEvens(10). You should see5(the evens 2, 4, 6, 8, 10). -
Now print
countEvens(0). What happens? Is that what you expected?
Don't move on until your code works for both countEvens(10) and countEvens(100) (expected: 50).
What you might wonder¶
"Why do I have to declare the return type?"
So Go can catch mistakes for you at compile time. If you say double returns an int and you accidentally write return "hello", Go refuses to build the program and tells you exactly what's wrong. The alternative - types figured out at runtime, like in Python - means some bugs only show up when a real user hits them. Go trades a small amount of typing for a large reduction in runtime surprises.
"What if my function has nothing to return but I forgot to handle a case?"
If you declare a return type but a path through your function doesn't return anything, Go refuses to compile. You'll see "missing return at end of function." This is on purpose - it catches the "I forgot the else branch" bug.
"Can a function call itself?" Yes. That's called recursion and it's a useful tool for certain problems. We'll meet it later when we have something worth recursing through.
"Can I have two functions with the same name?" No, not in the same package. Pick distinct names.
Done¶
You can now: - Define your own functions with parameters and a return type. - Return zero, one, or two values from a function. - Call functions from other functions. - Use functions to give names to operations and compose programs out of small pieces.
You've now learned the core of imperative programming in Go: variables, types, expressions, decisions, loops, and functions. Every Go program - including the ones in real OSS projects you'll eventually contribute to - is built from these primitives. The remaining pages introduce things that make those primitives more powerful: ways to group data (structs), ways to handle many things (slices and maps), ways to handle failure (errors), and so on.
The next page introduces structs - how Go lets you make your own types that group related data together. This is where your programs start to model real-world things.
Next: Making your own types → 05-structs-and-methods.md
05 - Making Your Own Types¶
What this session is¶
About an hour. You'll learn how to define your own types by grouping related data together (a struct), and how to attach methods to those types (functions that "belong to" a type). This is where Go starts to feel less like a calculator and more like a tool for modeling real things.
The problem this solves¶
So far, every piece of data you've worked with has been a single value - one int, one string, one bool. Real things have many properties at once. A person has a name, an age, a city. A point on a graph has an x and a y. A rectangle has a width and a height.
You could pass each property as a separate parameter:
func describe(name string, age int, city string) {
fmt.Printf("%s, age %d, lives in %s\n", name, age, city)
}
That works for two or three. At six, you're sad. At twelve, you're lost. A struct lets you bundle them up.
A struct¶
package main
import "fmt"
type Person struct {
Name string
Age int
City string
}
func main() {
alice := Person{Name: "Alice", Age: 30, City: "Lagos"}
fmt.Println(alice)
fmt.Println(alice.Name)
fmt.Println(alice.Age)
}
Type it. Run it. You'll see:
What's new:
type Person struct { ... }- defines a new type calledPerson. It's a struct (a bundle of fields). The fields are listed inside the braces, one per line, each with a name and a type.Person{Name: "Alice", Age: 30, City: "Lagos"}- creates a value of typePerson, with the listed fields set. This is called a struct literal.alice.Name- dot notation to read a field. Works the same way to write:alice.Name = "Alicia"changes the name.
Naming convention: types are CapitalCase. Field names are also CapitalCase if you want them visible outside the package (more on that in page 11). For now, capitalize everything.
Structs as function parameters and returns¶
A struct value is just like any other value - you can pass it to functions and return it from them:
package main
import "fmt"
type Point struct {
X, Y int
}
func origin() Point {
return Point{X: 0, Y: 0}
}
func describe(p Point) {
fmt.Printf("at (%d, %d)\n", p.X, p.Y)
}
func main() {
o := origin()
describe(o)
describe(Point{X: 3, Y: 4})
}
Notice X, Y int - when adjacent fields share a type, you can group them on one line. Same idea as parameters.
Methods: functions attached to a type¶
A method is a function that belongs to a type. The only difference from a regular function is the receiver - an extra parameter declared before the function name.
package main
import (
"fmt"
"math"
)
type Point struct {
X, Y float64
}
func (p Point) DistanceFromOrigin() float64 {
return math.Sqrt(p.X*p.X + p.Y*p.Y)
}
func main() {
p := Point{X: 3, Y: 4}
fmt.Println(p.DistanceFromOrigin()) // 5
}
The line:
reads as: "define a method called DistanceFromOrigin. It's attached to the Point type. Inside the method, the value the method was called on is named p."
So p.DistanceFromOrigin() calls the method with p as the receiver.
New thing in this example: float64 is a type for numbers with decimal places. math.Sqrt takes a float64. We changed Point.X and Point.Y from int to float64 so it works.
You could have written this as a regular function:
That's not wrong. But methods read better at the call site:
The method form puts the most important thing (the point) first. As programs get bigger, this matters.
Pointer receivers (preview)¶
There's a second form of receiver, with a * in front of the type:
This is called a pointer receiver. The short version: use it when the method needs to modify the value. The version with just Point (no *) is called a value receiver; the method works on a copy and changes don't stick.
You'll learn pointers properly in Page 08 - don't worry about the details now. Just notice the syntax. The rule of thumb for now: if the method changes a field, use *Type as the receiver.
Constructors (a convention, not a feature)¶
Go has no special "constructor" keyword. The convention is to write a regular function that returns a value of your type, named NewYourType:
func NewPerson(name string, age int) Person {
return Person{Name: name, Age: age, City: "unknown"}
}
// Use it:
alice := NewPerson("Alice", 30)
This is just a function. The pattern gives you somewhere to put validation, defaults, or setup logic. Look for NewX functions in any Go library - they're everywhere.
Exercise¶
In a new file shapes.go:
-
Define a struct
Rectanglewith twofloat64fields:WidthandHeight. -
Add two methods on
Rectangle: Area() float64- returnsWidth * Height.-
Perimeter() float64- returns2 * (Width + Height). -
In
main, create aRectanglewith width 5 and height 3. Print its area and perimeter. (Expected: 15 and 16.) -
Write a regular function
LargerOf(a, b Rectangle) Rectanglethat returns whichever rectangle has the bigger area. Test it with two rectangles of different sizes. -
Add a constructor
NewSquare(side float64) Rectanglethat returns aRectanglewith both fields set toside. Test it:NewSquare(4).Area()should be16.
What you might wonder¶
"Why uppercase field names?" In Go, capitalizing a name makes it visible from outside the package (other people's code can see it). Lowercase keeps it private to the current package. You'll meet packages properly in page 11. For now: capital is the safer default.
"What's the difference between a struct and a class?" If you've used a language with classes (Java, Python, C++): a Go struct is similar but smaller. No inheritance. No constructors built in. Methods are attached separately (not inside the type declaration). Go gets away with this minimalism because it has interfaces (page 11) for the cases other languages use inheritance for.
"Can I have a struct that contains another struct?"
Yes. Very common. A Person could contain an Address field of type Address. Field access chains: p.Address.Street.
"What's the zero value of a struct?"
If you write var alice Person without initializing fields, every field gets its zero value - 0 for numbers, "" for strings, false for booleans, nil for the things we haven't met yet. Useful: a fresh Person is always valid-looking, not garbage.
Done¶
You can now:
- Define your own types with type Name struct { ... }.
- Create struct values with the Type{Field: value, ...} literal.
- Read and write fields with dot notation.
- Pass structs to functions and return them.
- Attach methods to types with a receiver.
- Recognize the pointer-receiver syntax (*Type) without knowing pointers fully yet.
You can now model things that have multiple properties. Combined with what came before, you can write programs that work with people, products, accounts, points on graphs - anything with structure.
Next page: how Go handles collections - lists of things, lookups by key. Slices and maps.
Next: Many things at once → 06-slices-and-maps.md
06 - Many Things at Once¶
What this session is¶
About an hour. You'll learn the two most-used "collection" types in Go: slices (an ordered list of things, like a shopping list) and maps (a lookup table, like a dictionary where you look up a word to find its meaning). Almost every real Go program uses both, constantly.
Why you need these¶
Until now, every variable has held one thing. One name. One age. One person. Real programs deal with many things. A list of users. A count of how many times each word appears in a document. The temperatures for each day of the week. You need types built for collections.
Go gives you three: arrays, slices, and maps. Slices and maps are what you'll actually use. Arrays exist, but you'll see them rarely - we'll mention them only enough to recognize them.
Slices: ordered lists¶
package main
import "fmt"
func main() {
names := []string{"Alice", "Bob", "Chioma"}
fmt.Println(names)
fmt.Println(names[0]) // Alice - slices are 0-indexed
fmt.Println(names[2]) // Chioma
fmt.Println(len(names)) // 3
}
Type and run.
What's new:
[]string{...}- a slice of strings. The[]says "a list of";stringsays "of strings".names[0]- read the first element. Indexing starts at 0, not 1. (names[2]is the third element.)len(names)- built-in function that gives the number of elements.
Common mistake: going off the end. names[3] would crash the program (Go calls this a "panic" - we'll meet it later). Always know how many elements you have before indexing.
Adding to a slice: append¶
Slices grow:
names := []string{"Alice", "Bob"}
names = append(names, "Chioma")
fmt.Println(names) // [Alice Bob Chioma]
append(slice, value) returns a new slice with the value added. You almost always reassign back to the same variable: names = append(names, "Chioma").
Two notes that will save you confusion later:
appendreturns a value; it doesn't change the slice in place. Forgetting thenames =part is a common bug - yourappenddoes nothing visible.- You can append several values:
append(names, "D", "E", "F").
Looping over a slice: range¶
You've seen for i := 0; i < len(names); i++ style loops on page 03. For slices, there's a friendlier form:
Output:
range names gives you two values per iteration: the index and the value at that index. If you don't need the index, use _:
The underscore _ means "I don't care about this." Go won't compile if you create a variable and don't use it (page 02) - _ is the official way to say "throw this away."
A slice of structs¶
Combine what you know:
type Person struct {
Name string
Age int
}
people := []Person{
{Name: "Alice", Age: 30},
{Name: "Bob", Age: 25},
{Name: "Chioma", Age: 35},
}
for _, p := range people {
fmt.Println(p.Name, "is", p.Age)
}
A slice can hold any type - structs you defined, other slices, anything.
Maps: looking things up by key¶
A map is a collection where each entry has a key (the thing you look up by) and a value (the thing you get back).
package main
import "fmt"
func main() {
ages := map[string]int{
"Alice": 30,
"Bob": 25,
"Chioma": 35,
}
fmt.Println(ages["Alice"]) // 30
fmt.Println(ages["Bob"]) // 25
fmt.Println(len(ages)) // 3
}
What's new:
map[string]int{...}- a map whose keys arestrings and whose values areints. Read it as "a map FROM string TO int."ages["Alice"]- look up the value for the key"Alice".
Add or update an entry:
Delete an entry:
The "comma ok" pattern: is the key there?¶
What if you look up a key that isn't in the map?
You get the zero value for the value type - 0 for int, "" for string, etc. (We met zero values in page 05.) That's sometimes fine, but often you need to know whether the key was there or not. Use this form:
score, ok := ages["Zara"]
if ok {
fmt.Println("found:", score)
} else {
fmt.Println("not in the map")
}
The second return value (we named it ok) is a bool: true if the key was present, false if not. This is called the "comma ok" pattern. You'll see it constantly in Go code.
Looping over a map¶
Important
the order is not guaranteed. If you run the program twice, the output order may differ. This is on purpose - Go randomizes map iteration order so you don't accidentally rely on an order that the language doesn't promise.
If you need ordered output, sort the keys first:
import "sort"
keys := make([]string, 0, len(ages))
for k := range ages {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Println(k, ages[k])
}
That make([]string, 0, len(ages)) creates a slice of strings with length 0 and capacity for len(ages) elements - a small optimization that avoids the slice growing as we append. You'll see make more in real code; for the basics, []string{} works the same.
Arrays (briefly, so you recognize them)¶
You'll occasionally see code with a number inside the brackets:
That's an array - fixed size (7, here), declared at compile time. Once made, you can't grow it. In practice, you'll use slices instead. The only time arrays matter for beginner-level Go is when you're reading code that uses them - recognize the shape, then mentally translate to "this is just a fixed-size collection."
Going deeper¶
The basics above are enough to write real Go. But there are five things that surprise people about slices and maps, and you'll hit each one at some point. Reading this section ahead of time saves you a debugging session.
A slice is a tiny struct, not a list¶
When you write s := []int{1, 2, 3}, Go creates two things:
- An array somewhere in memory holding
1, 2, 3. - A slice header - a tiny struct with three fields: a pointer to that array, a length (
3), and a capacity (also3).
That slice header is what s actually is. When you pass s to a function, you copy the header - pointer, length, capacity - but not the underlying array. Two slice variables can point at the same array. That's usually fine, until it isn't (see the next section).
You can check capacity at any time:
s := []int{1, 2, 3}
fmt.Println(len(s), cap(s)) // 3 3
s = append(s, 4)
fmt.Println(len(s), cap(s)) // 4 6 (Go grew the backing array; doubled it)
append is the one place this gets visible. If there's room (cap > len), append writes into the existing array. If there isn't, append allocates a new bigger array, copies the old data over, and returns a slice header pointing at the new one. That's why you always write s = append(s, x) - the returned header may be different from the input header.
The aliasing gotcha¶
Because slices share the underlying array, this surprises people:
a := []int{1, 2, 3, 4, 5}
b := a[:3] // b is {1, 2, 3}, sharing a's array
b[0] = 99
fmt.Println(a) // [99 2 3 4 5] -- a changed too!
b is a new slice header, but its pointer is into a's array. Writes through b modify the same memory.
The same effect bites with append:
a := []int{1, 2, 3, 4, 5}
b := a[:3] // len=3, cap=5
b = append(b, 99) // b's cap was 5; append wrote into a's memory
fmt.Println(a) // [1 2 3 99 5] -- the "4" got overwritten
If you ever pass a sub-slice to a function that might append, copy it first:
The Go standard library does this all over the place. It's worth recognizing.
Map iteration order is randomized on purpose¶
The order changes every run. Go does this deliberately to stop you from accidentally relying on order. If you need deterministic order, sort the keys:
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Println(k, m[k])
}
This is one of the top "passes on my laptop, fails in CI" bug sources for Go beginners.
Tell Go the size when you know it¶
If you know roughly how many items you'll have, say so:
// Bad: 10 allocations as the slice grows
s := []int{}
for i := 0; i < 1000; i++ {
s = append(s, i)
}
// Good: 1 allocation
s := make([]int, 0, 1000)
for i := 0; i < 1000; i++ {
s = append(s, i)
}
Same for maps:
In hot paths this is a real performance win. In cold paths it's a discipline that makes intent clear to readers.
nil slices behave (mostly) like empty slices¶
var s []int // nil slice
fmt.Println(len(s)) // 0
s = append(s, 1) // works! append handles nil
for v := range s { // works on empty too
fmt.Println(v)
}
A nil slice has no backing array. An empty slice ([]int{}) has a header but length zero. For most purposes they're interchangeable - len, range, and append all work the same.
The one place it matters is JSON: nil slices marshal to null, empty slices marshal to []. If your API consumer expects [], return []int{}, not nil.
Exercise¶
In a new file wordcount.go:
Write a program that counts how many times each word appears in a sentence.
-
Start with this sentence:
"the quick brown fox jumps over the lazy dog the end". Hardcode it as a string. -
Split it into words. Use:
strings.Fieldssplits a string on whitespace and returns a[]string. -
Build a
map[string]intwhere each key is a word and each value is how many times it appeared. -
Print each word and its count, one per line.
Expected output (order may differ):
Stretch: print the words sorted alphabetically. (Use the sorted-keys pattern above.)
What you might wonder¶
"Why is indexing zero-based?" Historical and mathematical reasons that took root in C and spread. Slightly painful at first, then automatic. You'll stop thinking about it in two weeks.
"What's the difference between a slice and an array, really?"
A slice is a view into a backing array, with its own length and capacity. The slice can grow (via append); the underlying array doesn't (it gets replaced with a bigger one when needed). For now: use slices, ignore arrays. The full picture lands when you read more Go code.
"Can a map's keys be any type?"
Almost. Keys must be types that can be compared with ==. Strings, numbers, booleans, structs of those - yes. Slices, maps, functions - no (they can't be compared). 95% of the time the answer is "string" or "int."
"What if I append to a slice that other code is also looking at?"
You'll meet a subtle bug eventually. Don't worry about it now. Rule of thumb: don't hold onto a slice across an append you didn't do yourself.
Done¶
You can now:
- Build ordered lists with slices: create, index, len, append, range.
- Use the i, v := range slice and _, v := range slice patterns.
- Build lookup tables with maps: create, read, write, delete.
- Check whether a key exists with the comma-ok pattern.
- Iterate a map, sort keys when order matters.
- Recognize an array when you see one (and convert it mentally to "fixed-size slice").
Most of the data shape work in Go is done with slices and maps. You now have the basic vocabulary that every Go program uses.
Next page: how Go handles things going wrong - the "errors as values" pattern.
Next: Errors → 07-errors.md
07 - Errors¶
What this session is¶
About an hour. You'll learn how Go handles things going wrong - opening a file that doesn't exist, parsing a number from text that isn't a number, network calls that fail. Go's approach is different from most other languages and one of the things that makes Go code recognizable on sight.
The big idea: errors are values¶
In many languages (Python, Java, JavaScript, Ruby), when something goes wrong the language throws an exception that flies up the call stack until someone catches it. If nobody catches it, the program crashes.
Go does not do this. In Go, a function that can fail returns an extra value: an error. The caller is expected to check it.
result, err := SomethingThatCanFail()
if err != nil {
// handle the failure
return err
}
// use result, now that we know it's safe
That if err != nil { ... } block is the most-typed pattern in all of Go. Some people love it; some people complain about it. Either way, you can't read Go code without it.
A real example¶
The standard library has strconv.Atoi, which converts a string to an integer ("Atoi" = "ASCII to integer"). It might fail, because "hello" isn't a number. So it returns two things:
package main
import (
"fmt"
"strconv"
)
func main() {
n, err := strconv.Atoi("42")
if err != nil {
fmt.Println("could not parse:", err)
return
}
fmt.Println("got the number:", n)
n2, err := strconv.Atoi("hello")
if err != nil {
fmt.Println("could not parse:", err)
return
}
fmt.Println("got the number:", n2) // never reached
}
Type and run. Output:
What's happening:
strconv.Atoi("42")succeeds.errisnil(the special "nothing" value). We skip theifand usen.strconv.Atoi("hello")fails.erris a non-nil error value with a useful message. We enter theif, print it, andreturnfrom main - which ends the program.
That nil thing is new. nil is Go's word for "no value, nothing here." When a function returns (T, error):
- If everything was fine: the
Tis the real result anderrorisnil. - If something failed: the
Tis usually the zero value (don't trust it), anderroris the actual error.
You always check err != nil before trusting the other return value. Always. There are no exceptions to this rule for code you write.
What is an error, exactly?¶
An error is just a value with a method called Error() that returns a string. You'll learn the details (it's an "interface", page 11) later. For now: think of error as a type that knows how to describe itself. err.Error() gives you the string; fmt.Println(err) calls it automatically.
Making your own errors¶
You'll write functions that can fail. They need to return errors.
The simplest way:
import "errors"
func divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("divide by zero")
}
return a / b, nil
}
errors.New("...") creates a brand-new error with the given message. Use it for simple cases.
The fancier way uses fmt.Errorf, which is like fmt.Sprintf but produces an error:
import "fmt"
func divide(a, b int) (int, error) {
if b == 0 {
return 0, fmt.Errorf("cannot divide %d by zero", a)
}
return a / b, nil
}
The advantage: you can include variables in the error message, exactly like Println.
Wrapping errors with %w¶
Often a function fails because another function it called failed. You want to include the deeper error in your own error message:
import (
"fmt"
"strconv"
)
func parseAge(input string) (int, error) {
n, err := strconv.Atoi(input)
if err != nil {
return 0, fmt.Errorf("parsing age %q: %w", input, err)
}
if n < 0 {
return 0, fmt.Errorf("age cannot be negative, got %d", n)
}
return n, nil
}
The %w placeholder wraps the original error inside your new one. Why this matters:
- The error message includes everything: "parsing age \"hello\": strconv.Atoi: parsing \"hello\": invalid syntax". Helpful when debugging.
- Other code can later unwrap it (
errors.Is,errors.As) to check whether a specific underlying error happened. You'll see this in real codebases.
For now, the rule: when you return an error that was caused by another error, wrap it with %w.
The pattern, end-to-end¶
A function that does several things, any of which can fail:
func loadConfig(path string) (Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return Config{}, fmt.Errorf("reading %s: %w", path, err)
}
cfg, err := parseConfig(data)
if err != nil {
return Config{}, fmt.Errorf("parsing %s: %w", path, err)
}
if err := cfg.Validate(); err != nil {
return Config{}, fmt.Errorf("validating %s: %w", path, err)
}
return cfg, nil
}
Three things, three if err != nil blocks, three wrapped error messages. Every error tells you which step failed and what happened underneath. This shape - try a step, check, maybe wrap and return - is the rhythm of Go.
It is verbose. People who come from exception-based languages often dislike this initially. The argument for it: every failure path is right there in the code, visible and explicit, not hidden in a throws clause or a catch block somewhere else.
What about panics?¶
You'll occasionally see panic("message") in Go code. A panic is a program-level crash - used for "this should never happen, the program is broken." Examples: dividing by zero (the language panics for you), trying to read index 99 of a 3-element slice, dereferencing a nil pointer.
Don't use panic for normal failure cases. "File not found" isn't a panic; it's an error. Panic is for "the world is broken and there's no recovering." You'll see it in init code (a service can't start without its config) and in development assertions ("this case should be impossible - if it happens, I want to know loudly").
There's a way to catch panics (recover) but you'll almost never need it. Use errors for almost everything.
Going deeper¶
The basics above are enough to write real Go error handling. Real-world Go codebases use a few more tools that aren't strictly necessary but make life easier once you reach for them.
Sentinel errors and errors.Is¶
A sentinel error is a package-level error value that callers can compare against:
package mystore
import "errors"
var ErrNotFound = errors.New("item not found")
func Lookup(id string) (Item, error) {
if id == "" {
return Item{}, ErrNotFound
}
// ...
}
The caller:
item, err := store.Lookup(id)
if errors.Is(err, mystore.ErrNotFound) {
// handle "not found" specifically
return nil
}
if err != nil {
return err
}
errors.Is walks the wrapping chain - if err is ErrNotFound, or wraps it, or wraps something that wraps it, it returns true. You'll see this everywhere: io.EOF, sql.ErrNoRows, context.Canceled, os.ErrNotExist. The pattern is the same - define a sentinel, wrap it when adding context, compare with errors.Is.
Custom error types and errors.As¶
When you need more than a yes/no - say, "what HTTP status should this map to?" - use a typed error:
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation: %s: %s", e.Field, e.Message)
}
The caller extracts the type:
err := DoStuff()
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Println("bad field:", ve.Field)
}
errors.As is the typed cousin of errors.Is. Walks the wrap chain looking for an error whose dynamic type matches the target.
When NOT to wrap¶
Wrapping with %w adds context but also chains the underlying error into the public API of your function. Two cases where you should not wrap:
- You don't want callers to depend on the inner error. If you wrap
os.ErrNotExistand callers start writingerrors.Is(err, os.ErrNotExist), you can never change the underlying mechanism without breaking them. Use%vto flatten the error into a string instead. - You're returning an error from someone else's package without adding info. Just return it. No need for
fmt.Errorf("wrapping: %w", err)that adds nothing.
The rule: wrap when you're adding context that's useful to a caller. Don't wrap as a reflex.
errors.Join for multiple errors¶
Sometimes you want to keep going through a batch and report everything at the end:
import "errors"
func processAll(items []Item) error {
var errs []error
for _, item := range items {
if err := process(item); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
errors.Join (Go 1.20+) bundles many errors into one. errors.Is and errors.As both check every joined error. The joined value's Error() is the inner errors' messages concatenated with newlines.
When panic is correct¶
The "never panic" advice has a few exceptions:
- Program invariant broken. Your function takes a non-nil arg and got nil. The caller violated the contract. Panic; let the caller fix their bug.
- Init failure with no recovery. Your service can't start without a config file. Returning an error from
init()isn't possible, and continuing with bad state is worse. MustXxxconstructors. A common idiom:regexp.MustCompile,template.Must. They panic on invalid input. Use them only when the input is a compile-time constant that you've verified by reading the code.
recover exists for one good reason: catching panics at a goroutine boundary so they don't crash your whole server. HTTP server frameworks use it for that. You probably won't write it yourself.
Exercise¶
In a new file parse.go:
Write a function parsePositive(s string) (int, error) that:
- Parses
sas an integer usingstrconv.Atoi. - If
Atoifails, returns the error wrapped with%wand a message like"parsePositive %q: %w". - If the parsed number is less than or equal to zero, returns an error like
"parsePositive: number must be positive, got %d"(usefmt.Errorf, no wrap because there's no underlying error to wrap). - If everything is fine, returns the number and
nil.
In main, call parsePositive with each of these inputs and print the result:
- "42" → expect 42, no error
- "hello" → expect parse error
- "-5" → expect "must be positive" error
- "0" → expect "must be positive" error
- "100" → expect 100, no error
Loop over them with a slice: inputs := []string{"42", "hello", "-5", "0", "100"}.
What you might wonder¶
"Why isn't this try/catch like other languages?"
Design choice. Exceptions hide control flow - a function call may secretly jump to a handler 10 frames up the stack. Go's authors decided that making every failure path explicit was worth the extra typing. You don't have to agree with the choice; you do have to read the code.
"What's nil?"
Go's "no value" for types that can be absent - errors, pointers (page 08), maps that haven't been made yet, slices that haven't been initialized, channels. nil compared with == lets you check for "nothing here." if err != nil literally means "if there is an error."
"Is if err != nil really every other line?"
Roughly, yes. Modern Go has a few patterns to reduce it (helpers, error groups), but expect to see and write the pattern often. You'll stop noticing it in a few weeks.
"What's %q?"
A fmt placeholder that prints a value in quoted form: "hello" rather than hello. Useful in error messages so you can tell whitespace and empty strings apart.
Done¶
You can now:
- Recognize that Go functions that can fail return (T, error).
- Write the if err != nil { return ..., err } pattern.
- Create errors with errors.New and fmt.Errorf.
- Wrap one error inside another with %w.
- Tell errors apart from panics (and know when to use each).
You've now seen Go's most distinctive idiom. Real Go code is mostly: data shaping (slices, maps, structs), decisions and loops, function composition, and error checking. You have all five.
Next page: pointers - how Go lets you share data and let functions mutate things.
Next: Pointers and memory → 08-pointers-and-memory.md
08 - Pointers and Memory¶
What this session is¶
About an hour. Pointers are the topic that scares beginners the most and turns out to be small once you actually meet them. By the end of this page you'll understand what a pointer is, when to use one, and why methods sometimes have *Type in their receiver.
We will go slowly. Pointers feel weird the first three times.
The problem¶
When you pass a value to a function, Go gives the function its own copy. Changes the function makes don't affect the caller.
package main
import "fmt"
type Counter struct {
Value int
}
func increment(c Counter) {
c.Value = c.Value + 1
}
func main() {
counter := Counter{Value: 0}
increment(counter)
fmt.Println(counter.Value) // 0 - not 1!
}
Type and run. Surprise: the counter is still 0. Why?
increment received its own copy of the counter. It bumped that copy. The original is untouched.
For numbers this is fine - most functions don't want to mutate their inputs. But sometimes you do. You want increment(counter) to actually increment the counter. That's what pointers are for.
What a pointer is¶
A pointer is a value that stores the address of another value. Instead of being "the counter," a pointer is "where the counter lives."
Two operators:
&x- read as "the address of x." Gives you a pointer tox.*p- read as "the value pointed to by p." Goes from pointer to actual value. Called dereferencing.
package main
import "fmt"
func main() {
x := 42
p := &x // p is a pointer to x
fmt.Println(x) // 42
fmt.Println(p) // something like 0xc00001a098 - an address
fmt.Println(*p) // 42 - what p points to
*p = 100 // write through the pointer
fmt.Println(x) // 100 - x changed!
}
Type and run carefully. Notice the two important moves:
p := &xmadeppoint atx. Nowpknows wherexlives.*p = 100followed the pointer back toxand wrote100into it.xis now100.
The type of p is *int. Read it as "pointer to int." You'll see this notation in function signatures: func foo(n *int) means "foo takes a pointer to int."
Fixing the counter¶
package main
import "fmt"
type Counter struct {
Value int
}
func increment(c *Counter) {
c.Value = c.Value + 1
}
func main() {
counter := Counter{Value: 0}
increment(&counter)
fmt.Println(counter.Value) // 1!
}
Two changes:
func increment(c *Counter)- the parameter is now a pointer to aCounter, not a Counter.increment(&counter)- we pass the address ofcounter, not a copy.
Inside increment, c.Value = ... writes through the pointer to the original. The change sticks.
Notice: even though c is a pointer, we wrote c.Value, not (*c).Value. Go quietly does the dereferencing for field access. This is purely a convenience. Both forms work; the short one is what everyone writes.
Pointer receivers on methods (revisited from page 05)¶
Now the * on a method receiver makes sense:
This says: "the receiver is a pointer to a Counter, not a copy of one." When you call counter.Increment(), Go quietly passes the address of counter, the method mutates through it, the change sticks.
Compare to a value receiver:
This compiles, runs, and does nothing visible. A classic beginner bug.
The rule of thumb (stronger version of what page 05 said):
- If the method modifies the receiver's fields → use
*Type. - If the receiver is a big struct (lots of fields) → use
*Typeeven if you don't modify it. Saves the copy. - If the receiver is small and immutable → either works; pick one and be consistent across all methods on the type.
When in doubt, use *Type. Real Go code uses pointer receivers ~80% of the time.
nil pointers¶
A pointer can be nil - pointing at nothing. Dereferencing a nil pointer is a panic (crash).
var p *int // p is nil
fmt.Println(p == nil) // true
fmt.Println(*p) // PANIC: nil pointer dereference
You'll see this crash often when starting out. The fix is almost always "make sure the pointer is set to something before you use it." Either initialize it (p := &x) or check before dereferencing:
When to use pointers¶
The honest answer:
- A method needs to mutate its receiver. Use a pointer receiver.
- You're passing a large struct around and don't want to copy it every time. Use a pointer.
- You explicitly want a "nullable" value - a thing that might or might not be set. Use a pointer; check
nil. - You're sharing state between functions, and they all need to see updates. Use a pointer.
Otherwise: pass values around. Copying is cheap for small things. Go's compiler is good at avoiding actual copies when it safely can.
What about memory? Stack vs heap?¶
You may have heard programmers talk about "stack" and "heap." Quick story:
- The stack is fast memory used for function calls - every call grows it; every return shrinks it. Limited size.
- The heap is general-purpose memory that outlives any single function call. Garbage-collected (Go cleans up things you stop using).
Go decides for you. The compiler does escape analysis to figure out whether a value can safely live on the stack (faster) or must live on the heap (longer-lived). You almost never have to think about it.
The only thing you need to know now: taking the address of something with & may or may not cause it to live on the heap. Don't worry about it. Write clear code; let the compiler decide.
The full picture lands when you read "Go runtime" content later. For now: trust the language.
Going deeper¶
You can write production Go without knowing any of this. But a few things will eventually matter for performance, debugging, or just reading senior code with confidence.
Watch the compiler decide: -gcflags="-m"¶
You can ask the compiler to tell you what escaped to the heap and why:
$ go build -gcflags="-m" main.go
./main.go:5:6: can inline counter
./main.go:11:8: &n escapes to heap: flow: ~r0 = &n
./main.go:11:8: moved to heap: n
"Moved to heap" means: this variable, which looks local, actually lives on the heap because something escaped. Most of the time that's fine. In hot paths it's worth reducing.
The two common reasons something escapes:
1. You return a pointer to it. The caller might keep that pointer past your function's lifetime, so the value can't die when your function does.
2. You store it in something whose lifetime the compiler can't prove (e.g. an interface{} value passed to fmt.Println - the compiler often can't tell what that function will do with it).
You won't tune this for normal code. You will eventually use it to chase a benchmark.
Struct field order matters (a little)¶
Go aligns struct fields on natural boundaries. A bool is 1 byte but the next field might need to start on an 8-byte boundary, leaving 7 bytes of padding:
type Sparse struct {
A bool // 1 byte
// 7 bytes padding
B int64 // 8 bytes
C bool // 1 byte
// 7 bytes padding
} // 24 bytes total
type Dense struct {
B int64 // 8 bytes
A bool // 1 byte
C bool // 1 byte
// 6 bytes padding at the end
} // 16 bytes total
Dense packs the same fields into 16 bytes by grouping the wide fields first. For one struct, 8 bytes is nothing. For a slice of a million of them, it's 8 MB.
The order isn't a style rule - readability comes first - but if a struct is allocated in huge quantities and you're hunting bytes, group wide fields together.
sync.Pool for hot allocations¶
Suppose your HTTP handler builds a 4KB buffer for every request. Each allocation pressures the garbage collector. sync.Pool lets you reuse them:
var bufPool = sync.Pool{
New: func() any { return make([]byte, 0, 4096) },
}
func handle(w http.ResponseWriter, r *http.Request) {
buf := bufPool.Get().([]byte)[:0] // grab one, reset length
defer bufPool.Put(buf)
// ... use buf ...
}
A pool is a per-goroutine cache. Two surprises:
- The pool can drop anything any time (especially during GC). It's a cache, not storage.
- Get returns a value that might still have stuff in it from before. Reset before using.
Production servers use this heavily for buffers and request structs. Don't reach for it preemptively - only when allocation profiles show it's worth it.
Stacks grow (Go's secret weapon)¶
Each goroutine starts with a tiny stack - 8 KB on modern Go. If your function calls go deeper than that, the runtime detects it, allocates a bigger stack, copies the current stack into the new one, fixes up pointers, and continues. You see nothing.
This is why Go can run a million goroutines with reasonable memory: most of them never need more than the initial 8 KB. It's also why deep recursion in Go doesn't blow up like it does in some other languages - until you actually hit the per-goroutine cap (1 GB by default).
You'll never write code for this. But when someone says "goroutines are cheap because of growable stacks," now you know what they mean.
Reading a memory profile¶
When something feels slow, the next-level move is pprof:
import _ "net/http/pprof"
func main() {
go http.ListenAndServe("localhost:6060", nil)
// ... rest of your program ...
}
Now while your program runs:
You'll see which functions allocated the most memory. It's the Go-native answer to "where is my program spending bytes."
You'll learn this in depth later. For now: know it exists, know how to start it, recognize the output when you see it.
Exercise¶
In a new file account.go:
-
Define a struct
Accountwith two fields:Owner(string) andBalance(float64). -
Add a method
Deposit(amount float64)that addsamounttoBalance. Choose the right receiver type (value or pointer). -
Add a method
Withdraw(amount float64) errorthat: - Subtracts
amountfromBalanceifBalance >= amount. - Returns
nilon success. -
Returns an error like "insufficient funds: have X, want Y" otherwise.
-
In
main: -
Make sure both Deposit and Withdraw actually mutate
acct. (If they don't, you picked the wrong receiver type.)
What you might wonder¶
"Why don't I always use pointers, to be safe?"
Two reasons. (1) Pointers can be nil, which means an extra failure mode. (2) Pointers point at shared state, which means accidental mutation surprises. Values are simpler when simpler will do.
"Pointers in C scared me. Are Go pointers like that?"
A lot less scary. No pointer arithmetic (you can't p + 1 to "move along an array"). No manual free() - the garbage collector handles it. No casting between types. About 80% of what makes C pointers scary doesn't exist in Go.
"Is *Counter a different type from Counter?"
Yes, but Go is forgiving. You can call value-receiver methods on a pointer and vice versa most of the time. The type is technically distinct; the syntax is convenient.
"What's a **Counter?"
A pointer to a pointer. You'll see it rarely. Real-world reasons: a function that wants to replace what a pointer points to. Don't worry about it now; recognize it when you see it.
Done¶
You can now:
- Explain what a pointer is: a value holding the address of another value.
- Use &x to take an address and *p to dereference.
- Choose between value and pointer receivers on methods (mutation → pointer).
- Recognize and avoid nil pointer crashes.
- Stop fearing pointers.
You've now learned every basic mechanic of Go the language. From here on, the pages are about putting these mechanics together: concurrency (using all of them at once across multiple things running in parallel), testing (verifying your code does what you think), packages (organizing it), and eventually reading and contributing to real projects.
Next page: how Go does "do many things at once" - goroutines and channels.
Next: Concurrency 101 → 09-concurrency-101.md
09 - Concurrency 101¶
What this session is¶
About an hour. You'll learn the two primitives that make Go famous: goroutines (cheap concurrent "things going at the same time") and channels (the way they talk to each other). This page is an introduction - there's much more to learn later - but by the end you'll be able to make programs that do several things at once.
A note before we start: concurrency is the hardest concept in programming. If this page is the most confusing one so far, that's because it's actually the hardest topic, not because the page is bad. Be patient with yourself.
The problem¶
Suppose you need to download three web pages. Each takes 2 seconds. If you do them one after another, total time is 6 seconds. If you start all three at once and wait for them all to finish, total time is ~2 seconds.
That second pattern - doing multiple things at the same time - is concurrency. Go makes it easy.
Goroutines: doing things "at the same time"¶
A goroutine is a function running independently of the rest of your program. To start one, write go in front of a function call:
package main
import (
"fmt"
"time"
)
func say(msg string) {
for i := 0; i < 3; i++ {
fmt.Println(msg)
time.Sleep(500 * time.Millisecond)
}
}
func main() {
go say("hello")
go say("world")
time.Sleep(2 * time.Second)
fmt.Println("done")
}
Type and run. You should see "hello" and "world" interleaved, then "done."
What's new:
go say("hello")startssayrunning independently and immediately moves on to the next line. Two goroutines are now running.time.Sleep(500 * time.Millisecond)pauses for half a second.time.Sleep(2 * time.Second)inmainis there so the program doesn't exit before the goroutines finish.
That last point is critical. When main returns, the whole program ends - including any goroutines still running. If you remove the time.Sleep(2 * time.Second) and run again, you'll see almost no output: main starts the goroutines, immediately ends, the program quits.
Sleeping is a bad way to "wait for things to finish." We need a real mechanism.
sync.WaitGroup: wait for N things¶
The simplest "wait for goroutines to finish" tool is sync.WaitGroup. Think of it as a counter that goroutines decrement when they're done; main waits until it hits zero.
package main
import (
"fmt"
"sync"
"time"
)
func work(id int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Println("worker", id, "starting")
time.Sleep(1 * time.Second)
fmt.Println("worker", id, "done")
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go work(i, &wg)
}
wg.Wait()
fmt.Println("all workers finished")
}
Run it. Output (order may vary):
worker 1 starting
worker 3 starting
worker 2 starting
worker 1 done
worker 3 done
worker 2 done
all workers finished
New things:
var wg sync.WaitGroup- creates a WaitGroup. Zero-valued, ready to use.wg.Add(1)- bumps the counter up before starting each goroutine.defer wg.Done()- schedules aDonecall to happen when this function returns.Donedecrements the counter. (deferis a Go feature: "do this thing right before this function ends, no matter how it ends." Very useful.)wg.Wait()- blocks until the counter hits zero.go work(i, &wg)- note&wg. We pass a pointer so all goroutines share the same WaitGroup (not copies of it).
Total run time: ~1 second, not 3. Three workers ran at the same time, each took 1 second, total was 1 second. That's the win.
Channels: goroutines talking to each other¶
Often a goroutine produces a value and another goroutine needs to receive it. The Go way to pass values between goroutines safely is a channel.
A channel is like a pipe. One goroutine puts values in one end; another takes them out the other end.
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
ch <- 42
}()
value := <-ch
fmt.Println(value) // 42
}
New things:
make(chan int)- creates a channel that carriesintvalues.makeis a built-in for creating channels, slices, and maps.ch <- 42- send the value42into the channel. (Arrow points into the channel.)value := <-ch- receive a value from the channel. (Arrow points out of the channel.)go func() { ... }()- start an inline anonymous function as a goroutine. The()at the end immediately calls it. Common pattern.
Important: the receive <-ch blocks (waits) until something is sent. The send ch <- 42 blocks until something is received. The two goroutines meet at the channel and exchange the value. This is called synchronization.
A more useful example: fetching things in parallel¶
package main
import (
"fmt"
"time"
)
func fetch(url string, results chan<- string) {
// Pretend we're making an HTTP call.
time.Sleep(500 * time.Millisecond)
results <- "result from " + url
}
func main() {
urls := []string{"a.example", "b.example", "c.example"}
results := make(chan string)
for _, url := range urls {
go fetch(url, results)
}
for range urls {
fmt.Println(<-results)
}
}
Run. Total time: ~500 ms (not 1500 ms). All three "fetches" happen at the same time.
A new piece of syntax: chan<- string. The arrow direction in the type says "this channel can only be sent to, not received from." It's a hint to the reader (and the compiler) about how the channel is used. The opposite is <-chan string (receive-only). Plain chan string allows both.
In the main function, we loop for range urls (no index, no value - just "do this len(urls) times") and receive a result each iteration. We don't know which URL's result comes out when, but we know we get exactly three results because we started three goroutines.
Channels can be closed¶
When you're done sending on a channel, you can close it: close(ch). Receivers can then loop until the channel is empty and closed:
This loop ends when the channel is closed (and drained). Useful when you have an unknown number of values coming in.
Important
only the sender should close a channel, and only when no more values are coming. Closing a channel that you're not the sole sender of is a way to crash your program. For now, keep it simple: one goroutine sends, closes when done; one or more receive.
Common patterns and warnings¶
- Don't write to a closed channel. Panic.
- Don't close a channel from the receiver side. Confusing and error-prone.
- A nil channel blocks forever. If you do
var ch chan intwithoutmake, both send and receive hang forever.
These rules feel restrictive at first. They exist because the alternative is data races (multiple things touching the same memory simultaneously without coordination) which are the worst kind of bug - they appear randomly and are nearly impossible to reproduce.
The slogan¶
Go's tagline for concurrency:
Don't communicate by sharing memory; share memory by communicating.
In other languages, threads talk by reading and writing the same variables, protected by locks. In Go, the idiom is: each thing owns its own data, and passes copies via channels when other things need them. Less subtle, fewer bugs.
You'll still meet mutexes (sync.Mutex) in real Go code - sometimes a lock is the right tool. But for most tasks, channels are first.
Going deeper¶
Real-world concurrent Go is mostly the basics above plus five or six patterns you'll see in every serious codebase. Read this section to be ready when you encounter them.
context.Context for cancellation¶
If a goroutine is doing slow work (an HTTP request, a query), you need a way to say "stop, never mind." That's what context is for:
import "context"
func fetchSlow(ctx context.Context, url string) (string, error) {
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return string(body), nil
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_, err := fetchSlow(ctx, "https://slow.example.com")
if err != nil {
fmt.Println("gave up:", err)
}
}
The HTTP client checks ctx periodically; if it's canceled (timeout fired, or someone called cancel()), the request aborts. The convention in Go: any long-running function takes ctx as the first argument. The standard library is built around this.
You'll write func DoStuff(ctx context.Context, ...) constantly in production code.
Buffered vs unbuffered channels¶
The channel we used (make(chan int)) is unbuffered: send blocks until someone is ready to receive. Useful for handoffs.
A buffered channel holds N values:
ch := make(chan int, 5)
ch <- 1 // doesn't block
ch <- 2 // doesn't block
// ... 3 more before sending blocks
Three honest rules: 1. Default to unbuffered. Forces you to think about the handoff. 2. Use buffer = 1 for "I want to send and not wait for a receiver." Common for signaling. 3. Use buffer = N when you know N. A worker pool's job queue. A bounded retry buffer.
A buffered channel is not a queue you should fill up and ignore. If the buffer fills, the sender blocks just like an unbuffered channel.
The select statement¶
select lets a goroutine wait on multiple channel operations at once:
select {
case msg := <-input:
handle(msg)
case <-ctx.Done():
return ctx.Err()
case <-time.After(5 * time.Second):
return errors.New("timeout")
}
Whichever case is ready first wins. If none is ready, select blocks. With a default: case, select is non-blocking. This is the workhorse for any goroutine that has more than one thing to wait on - almost always combined with ctx.Done() for cancellation.
Worker pools (bounded concurrency)¶
"Do these 1000 things, but only 10 at a time":
jobs := make(chan Job, len(work))
results := make(chan Result, len(work))
// Start 10 workers.
for i := 0; i < 10; i++ {
go func() {
for j := range jobs {
results <- process(j)
}
}()
}
// Send work.
for _, w := range work {
jobs <- w
}
close(jobs) // workers' range loops will exit when channel drains
// Collect.
for i := 0; i < len(work); i++ {
r := <-results
// ... use r ...
}
close(jobs) lets the workers know there's no more work; their for j := range jobs loops exit cleanly. Without that close, the workers would block forever after the last job.
The golang.org/x/sync/errgroup package wraps this pattern with proper error handling. Use it; don't roll your own.
The race detector¶
Concurrency bugs are the worst kind: rare, hard to reproduce, often invisible until production. Go ships with a race detector. Run your tests with it:
If two goroutines access the same memory without coordination (and at least one is writing), the race detector reports it with full stack traces from both sides. Production servers should not run with -race (it's slow), but every test suite should.
The first time you turn it on in an existing codebase, brace yourself.
Goroutine leaks¶
A goroutine that blocks forever and never gets a chance to return is leaked. It holds memory and other resources. Common patterns:
// Leak: caller forgot to read from the channel
go func() {
ch <- expensiveCompute() // blocks forever if nobody reads
}()
// Leak: no cancellation; channel never closed
go func() {
for msg := range input { // blocks forever if no one closes input
handle(msg)
}
}()
The fix is always the same: every long-running goroutine should be reachable by a ctx.Done() channel or a closed input channel that lets its loop exit. If you can't draw an "and how does this goroutine end" arrow when you write it, you're leaking.
A production trick: dump goroutines with runtime.NumGoroutine() and pprof.Lookup("goroutine").WriteTo(os.Stdout, 1) periodically. A leak shows up as a number that only ever grows.
Mutexes still exist, and that's fine¶
The "share memory by communicating" advice is right most of the time. But sometimes you genuinely need shared state - a counter, a cache, a connection pool. Use sync.Mutex (or sync.RWMutex for read-heavy work):
type Counter struct {
mu sync.Mutex
value int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}
Two rules: hold the lock for as short a time as possible; never call out to user code while holding a lock (deadlock risk). The race detector catches forgotten locks the same way it catches channel-data races.
Exercise¶
In a new file parallel.go:
Write a program that:
- Has a slice of 10 numbers:
nums := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}. - For each number, in a separate goroutine, computes its square and sends the result on a channel.
- In
main, receives all 10 squares from the channel and adds them up. - Prints the total. (Expected: 385, the sum of 1² + 2² + ... + 10².)
Hints:
- Use make(chan int) for the results channel.
- Loop over nums starting goroutines, then a second loop (for range nums) receiving results.
- Add each received value to a running total.
Stretch: make a version using sync.WaitGroup and a regular slice instead of a channel (each goroutine writes to its own slot in a []int of length 10; main waits and sums). Compare which is easier to read.
What you might wonder¶
"Is a goroutine the same as a thread?" No, but close enough for now. A goroutine is much cheaper than an OS thread (you can have millions of goroutines without trouble; you can't have millions of threads). Under the hood, Go's runtime schedules many goroutines onto a small pool of threads. The full picture lives in the "Go Mastery" path; for now, treat a goroutine as "a thing that runs at the same time as other things."
"What happens if two goroutines write to the same variable without a channel or lock?"
A data race - undefined behavior, randomly-corrupt results, intermittent crashes. Go has a built-in detector: run your program with go run -race yourfile.go. It reports races at runtime. Run with -race whenever you write goroutines until you trust your code.
"When should I NOT use goroutines?" When the work is fast and sequential. Spinning up a goroutine has small overhead; for sub-microsecond tasks, you'll often be slower. Goroutines pay off when each unit of work takes more than ~10µs, or when units of work can genuinely happen at the same time (waiting on I/O, network, disk).
"What's select?"
A way to wait on multiple channels at once - receive from whichever one is ready first. Useful in real programs. Out of scope for an intro page; we'll meet it in real code in page 12.
Done¶
You can now:
- Start a goroutine with go funcCall().
- Wait for a known number of goroutines to finish with sync.WaitGroup.
- Create and use channels (make(chan T), ch <- v, <-ch).
- Range over a channel until it's closed.
- Understand the slogan "share memory by communicating."
- Recognize the major footguns: closed channels, nil channels, data races.
Concurrency is a big topic and we've only scratched the surface. The good news: you can write quite a lot of useful concurrent code with just these primitives.
Next page: writing tests for your code - so you know it works and you'll know when it stops working.
Next: Tests → 10-tests.md
10 - Tests¶
What this session is¶
About an hour. You'll learn how to write tests for your Go code, run them, and read their output. This is the page that changes you from "I wrote some code, it seemed to work" to "I wrote some code and I have proof it does what I think." It's also the page that makes contributing to OSS projects possible - every real project has tests, and you'll spend more time running them than writing them.
Why tests¶
When you change code, you might break something that used to work. The change you made looks fine. The thing that broke is in a file you haven't opened in three weeks. Without tests, you find out when a user does.
A test is a small program that calls your code with known inputs and checks that the outputs are what you expect. You run the tests after every change. If they all pass, you keep going. If one fails, you know exactly what broke.
This sounds obvious. Beginner programmers skip it for years because it feels like extra work. It is not extra work. It is the work that prevents three hours of debugging next week.
Go's testing setup¶
Go has testing built in. No installs, no frameworks, no configuration.
Convention:
- Test code lives in files ending in
_test.go. - Each test is a function named
TestXxx, taking one parametert *testing.T. - You run tests with
go test.
Set up:
go mod init mathutils creates a go.mod file declaring this folder as a Go module. We'll explain modules properly in page 11. For now, just run the command - it lets go test work.
Create a file math.go:
package mathutils
func Add(a, b int) int {
return a + b
}
func IsEven(n int) bool {
return n%2 == 0
}
Notice this file starts with package mathutils, not package main. There's no func main(). That's because this is a library package - code meant to be called from other code, not run directly.
Now create a file math_test.go (note the _test):
package mathutils
import "testing"
func TestAdd(t *testing.T) {
got := Add(2, 3)
want := 5
if got != want {
t.Errorf("Add(2, 3) = %d; want %d", got, want)
}
}
func TestIsEven(t *testing.T) {
if !IsEven(4) {
t.Error("IsEven(4) should be true")
}
if IsEven(7) {
t.Error("IsEven(7) should be false")
}
}
Run:
You should see:
Type go test -v for a verbose version showing each test by name. Useful when you have many tests.
The mechanics¶
func TestAdd(t *testing.T)- the name must start withTest(capital T) followed by a capital letter or digit. The parameter must be*testing.Texactly.t.Errorf(...)- reports a failure with a formatted message, but the test keeps running. Useful when one test has multiple checks.t.Error(...)- reports a failure with a plain message. Same: keeps running.t.Fatalf(...)/t.Fatal(...)- report a failure and stop this test immediately. Use when subsequent checks would crash (e.g., dereferencing a nil result).t.Logf(...)- prints output (only shown with-vor on failure). Useful for "diagnostic" messages.
The "got/want" idiom is the standard shape:
got := SomeFunction(input)
want := expectedValue
if got != want {
t.Errorf("SomeFunction(%v) = %v; want %v", input, got, want)
}
You'll see this exact shape in 99% of Go codebases. Mirror it.
Making a test fail (do this)¶
Open math.go, change Add to return a - b. Save. Run go test.
You'll see:
--- FAIL: TestAdd (0.00s)
math_test.go:9: Add(2, 3) = -1; want 5
FAIL
exit status 1
FAIL mathutils 0.001s
The test caught the bug. Notice how informative the error is - it tells you which test, which file/line, what input, what actual result, what expected result. That's the value.
Change Add back. Re-run. It passes again.
Table-driven tests: the idiomatic pattern¶
When you have many cases for the same function, don't write separate Test* functions. Write table-driven tests:
func TestIsEven(t *testing.T) {
cases := []struct {
input int
want bool
}{
{0, true},
{1, false},
{2, true},
{-4, true},
{-7, false},
{1000, true},
}
for _, c := range cases {
got := IsEven(c.input)
if got != c.want {
t.Errorf("IsEven(%d) = %v; want %v", c.input, got, c.want)
}
}
}
What's happening:
cases := []struct{ input int; want bool }{ ... }- a slice of anonymous structs, each with the inputs and expected outputs for one test case. (Yes, you can declare struct types inline.)- The loop runs each case as a sub-test.
This is the single most common test shape in Go code. When you read OSS Go projects, the test files will be ~80% table-driven. Recognize the pattern.
Subtests with t.Run¶
When you want each case to be reportable separately:
for _, c := range cases {
c := c // capture loop variable (subtle Go quirk pre-1.22)
t.Run(fmt.Sprintf("input=%d", c.input), func(t *testing.T) {
got := IsEven(c.input)
if got != c.want {
t.Errorf("got %v; want %v", got, c.want)
}
})
}
go test -v now shows each sub-test by name. go test -run TestIsEven/input=4 runs just one case. This is the production-grade form.
(The c := c line is a quirk: pre-Go-1.22, the loop variable was shared across iterations. The local re-declaration captured a fresh copy for each goroutine/subtest. In Go 1.22+, the loop semantics changed and you don't need this. Real code still has it, for backward compat. Recognize it.)
Test helpers¶
If you find yourself writing the same assertion in many tests, extract a helper:
func mustEqual(t *testing.T, got, want int) {
t.Helper()
if got != want {
t.Errorf("got %d; want %d", got, want)
}
}
t.Helper() tells the testing framework: "when reporting a failure from this function, don't point at this function - point at the line that called it." Without it, all your failures point at mustEqual, which isn't useful.
Running tests¶
| Command | What it does |
|---|---|
go test |
Run tests in the current package. |
go test -v |
Same, but verbose (show each test). |
go test ./... |
Run tests in current package and all sub-packages. |
go test -run TestAdd |
Run only tests matching the regex "TestAdd". |
go test -count=1 |
Disable test caching (force re-run). |
go test -race |
Run with the data-race detector. Always do this for code with goroutines. |
go test -cover |
Report what percentage of code your tests cover. |
go test -race is the one you should reach for any time you have goroutines. It catches data races that would otherwise be invisible until production.
Exercise¶
Set up a new package and test it.
-
Make a folder
~/code/go-learning/wordtools.cdinto it. Rungo mod init wordtools. -
Create
words.go: -
Create
words_test.go. Write table-driven tests for both: WordCount:""→ 0,"hello"→ 1,"hello world"→ 2," many spaces here "→ 3.-
IsPalindrome:""→ true,"a"→ true,"racecar"→ true,"hello"→ false,"Racecar"→ true (note: lowercased first). -
Run
go test -v. All tests should pass. -
Break each function on purpose, watch the relevant test fail, fix it, watch it pass.
What you might wonder¶
"Where do tests usually go in real projects?"
Right next to the code they test. foo.go and foo_test.go in the same folder, same package. This is universal in Go. You'll see it in every project.
"Should I write the test first or the code first?" Either works. Some people swear by "test-first" (TDD). The honest answer: any tests are infinitely better than no tests. Start by writing the code, then writing a test. After a few months, try writing the test first sometimes; see which feels better.
"How much test coverage should I aim for?" There's no magic number. 80% is a common target but it's a bad goal - you can hit 80% with tests that don't actually catch bugs. Better: every bug fix gets a test that would have caught the bug, and every important code path has at least one test.
"What about mocking?"
Mocking means replacing real dependencies (databases, network) with fake ones during a test. Go has tools for it (gomock, hand-written fakes); the idiomatic approach in Go is "use interfaces sparingly, prefer real or in-memory implementations." We'll meet interfaces in the next page; mocking is out of scope here.
Done¶
You can now:
- Set up a Go module with go mod init.
- Write tests in _test.go files with func TestXxx(t *testing.T).
- Use t.Errorf, t.Fatalf, t.Helper correctly.
- Write table-driven tests - the Go idiomatic shape.
- Run tests with go test, with -v for detail and -race for goroutine safety.
- Read a failing test's output and find the bug.
You can now verify your own code. More importantly, you can now read the test files in any real Go project and understand what they're checking. That's most of what makes a real codebase legible.
Next page: packages and modules - how Go code is organized into reusable pieces, and how you bring in code other people wrote.
Next: Packages and modules → 11-packages-and-modules.md
11 - Packages and Modules¶
What this session is¶
About an hour. You'll learn how Go code is organized (packages), how Go projects are versioned and shared (modules), how to bring in code other people wrote (go get), and the rule that decides what's visible outside a package (capitalization). This is the page that bridges you from "I write small programs" to "I work with real codebases."
Two related ideas¶
- A package is a folder of Go files that work together. Every Go file declares which package it belongs to (
package foo). - A module is a tree of packages with a
go.modfile at the root, declaring an import path and dependencies. Modules are how Go projects are versioned and shared.
You met a module briefly in page 10 (go mod init mathutils). Time to look properly.
Building a small multi-file package¶
Start fresh:
That creates go.mod:
Now create a sub-folder greet/ with greet.go:
package greet
import "fmt"
func Hello(name string) string {
return fmt.Sprintf("Hello, %s!", name)
}
func goodbye(name string) string {
return fmt.Sprintf("Bye, %s.", name)
}
And at the top level, create main.go:
package main
import (
"fmt"
"greetapp/greet"
)
func main() {
fmt.Println(greet.Hello("Alice"))
// fmt.Println(greet.goodbye("Alice")) // would not compile
}
Run from the top level:
Output: Hello, Alice!.
What's new:
- The
greet/greet.gofile starts withpackage greet. That's the name code uses to refer to it. main.gosaysimport "greetapp/greet"- the full path is<module>/<folder>. The first part comes fromgo.mod; the rest is the folder path.greet.Hello(...)- to use something from another package, prefix with the package name.
Exported vs unexported: the capitalization rule¶
This is one of Go's defining rules and one of the easiest to forget:
A name starting with a capital letter is exported (visible from outside the package). A name starting with a lowercase letter is not.
In greet.go:
- Hello is exported. main.go can call greet.Hello(...).
- goodbye is not exported. If you uncomment greet.goodbye(...) in main, the compiler will refuse with goodbye is not exported.
This rule applies to everything you can name: functions, types, struct fields, variables, constants, methods. Capitalize what should be visible from outside; lowercase what's an internal detail.
You don't pick this convention. The compiler enforces it. There's no public/private keyword in Go because of this rule.
The import block¶
When you import multiple packages, group them:
By convention: - Standard-library packages first (alphabetical). - A blank line. - Local and third-party packages second (alphabetical).
The tool goimports does this automatically; most editors run it on save.
Modules: a real example with a third-party dependency¶
Real projects don't just use the standard library. They bring in code from GitHub and elsewhere. Go's tool for this is go get.
Let's make a program that prints colored text. The library github.com/fatih/color is popular and small.
mkdir -p ~/code/go-learning/coloredhello && cd ~/code/go-learning/coloredhello
go mod init coloredhello
go get github.com/fatih/color
After go get, go.mod has a new line listing the dependency, and a go.sum file appears with checksums. Both should be committed if you put this in git.
Create main.go:
package main
import "github.com/fatih/color"
func main() {
color.Green("hello in green")
color.Red("hello in red")
color.Cyan("hello in cyan")
}
Run go run .. You should see three colored lines (assuming your terminal supports color, which most do).
go.mod and go.sum explained¶
After the above, go.mod looks roughly like:
module coloredhello- the import path of this module. If you publish to GitHub, this should begithub.com/yourname/yourrepo.go 1.22- the minimum Go version this code expects.require ...- direct dependencies, with versions.
go.sum has cryptographic hashes for every dependency (and their dependencies). It's how Go verifies you got exactly the same bytes everyone else gets when they fetch the same versions.
Both files are always committed to git. Together they make builds reproducible.
Useful module commands¶
| Command | What it does |
|---|---|
go mod init <path> |
Create a new module. |
go get <pkg> |
Add or upgrade a dependency. |
go get <pkg>@v1.2.3 |
Pin to a specific version. |
go mod tidy |
Add missing dependencies, remove unused ones. Run this before commits. |
go list -m all |
List all dependencies (direct + transitive). |
go mod why <pkg> |
Explain why a transitive dependency is in your graph. |
go mod tidy is the most useful. Run it whenever you've changed imports.
The standard library¶
The standard library ships with Go. You've already used it: fmt, errors, strings, strconv, time, sync, testing, math, os, sort.
A few more you'll meet:
- os - files, environment variables, command-line args.
- io - readers, writers (the universal abstractions for "stream of bytes").
- bufio - buffered I/O.
- net/http - HTTP client and server. Yes, Go ships a production-grade web server in the standard library.
- encoding/json - turn structs into JSON and back.
- path/filepath - manipulate file paths portably.
- log/slog - structured logging.
- context - pass cancellation and deadlines through call chains.
Browse the index at pkg.go.dev/std. The standard library is excellent and you should reach for it before any third party.
Interfaces (the briefest possible introduction)¶
You'll see the word interface in Go code constantly. Quick version:
An interface is a named set of methods. Any type that has those methods automatically satisfies the interface - no need to declare it.
Anything with a String() string method automatically "is a" Stringer. No implements keyword. The matching happens silently. This is called structural typing or duck typing with a type system.
The most common interface you'll see is io.Reader (anything with a Read method) and io.Writer (anything with a Write method). They're the reason files, network connections, gzip streams, and bytes-in-memory all look the same to code that just wants to read or write bytes.
Interfaces are a big topic. For a first read of OSS code, this is enough: when you see func foo(r io.Reader), the function works on anything that can be read from. Don't get bogged down.
Exercise¶
Two parts.
Part 1 - your own multi-file package:
- Start a new module:
mkdir ~/code/go-learning/bank && cd ~/code/go-learning/bank && go mod init bank. - Create
account/account.gowithpackage account. Put yourAccountstruct from page 08 (withOwner,Balance,Deposit,Withdraw) in it. Make the right things exported (capital first letter) and the right things unexported. - Create
main.goat the top level. Importbank/account. Create an account, deposit, withdraw, print results. - Run
go run .. - Try referencing an unexported name from
main.go. Read the error. Fix it.
Part 2 - a third-party library:
- In a new folder, start a module.
- Run
go get github.com/fatih/color. - Use one of the color functions (
color.Green,color.Red, etc.) to print something. - Run
go mod tidy. See what changes ingo.mod(probably nothing - you imported what you needed). - Look at
go.modandgo.sum. Identify which lines are about the library you added.
What you might wonder¶
"What's the difference between a package and a module?"
A package is a folder of related Go files (package foo at the top of each). A module is a versioned bundle of packages, with a go.mod at the root. One module typically has many packages.
"Why is the import path github.com/fatih/color?"
Go's convention is that the import path matches where the source lives - usually a URL. go get knows how to clone from github.com, gitlab.com, bitbucket.org, and others, just from the path.
"What's internal/?"
Any package inside a folder named internal/ is only importable by code in the same parent module. It's how libraries hide implementation details from their users. You'll see internal/ folders in almost every real Go project.
"How do I publish my own library?"
Push to a public git repo, tag a release like v1.0.0. Others can go get github.com/you/repo and use it. The full story is more nuanced (semver, breaking changes, replace directives) but that's the gist.
Done¶
You can now:
- Organize code into packages (folder = package).
- Understand the capital-letter export rule.
- Initialize a module with go mod init.
- Add third-party dependencies with go get.
- Read and reason about go.mod and go.sum.
- Recognize standard library imports vs third-party ones.
- Have a passing acquaintance with interfaces.
You've now covered every fundamental Go concept a beginner needs. The remaining pages are about applying them - reading real code, picking a project, contributing.
Next page: how to read code other people wrote without panicking.
Next: Reading other people's code → 12-reading-other-peoples-code.md
12 - Reading Other People's Code¶
What this session is¶
About 45 minutes. You'll learn the strategy for reading code you didn't write - a different skill from writing your own. This page has less code than usual; what it teaches is how to approach a new codebase without drowning. Master this and the difference between you and an experienced engineer narrows dramatically.
The mistake most beginners make¶
When you open a new codebase, the temptation is to start reading the first file you see and try to understand every line. By line 50 you're lost; by line 200 you've given up.
This doesn't work because real code isn't a story - it's a graph. Every function calls others. Every type is defined somewhere else. Trying to load it all into your head at once is impossible, even for experienced engineers.
The trick is to not try. Pick a small thread; follow only it; let the rest stay fuzzy.
The five-minute orientation¶
Whenever you open a new Go project, do exactly this, in order:
-
Read the README. What does this project DO? What is the one-sentence elevator pitch? If there's no README or you can't answer this, the project is too unfinished - pick another.
-
List the top-level directories. Common Go layout:
cmd/- actual programs you can run (one folder per binary).internal/- code that's private to this project.pkg/- public reusable packages (less common now; many projects skip this folder).api/- schema files (OpenAPI, protobuf, etc.).docs/- documentation.scripts/,hack/,tools/- helper scripts.-
vendor/- copy of dependencies (rare since Go modules; some projects still use it). -
Open
go.mod. What's the module path? What are the direct dependencies? This tells you which ecosystem the project lives in. -
Find the entry point. Usually
main.goorcmd/<name>/main.go. Readmainfrom top to bottom - but don't dive into every function it calls. Get a feeling for the shape: "it parses flags, builds a config, starts a server." -
Read one test file. Pick a small
_test.goand read it. Tests show you what the code is supposed to do, with concrete examples. Often clearer than the code being tested.
After this five-minute pass, you should be able to write a one-paragraph summary of what the project does. If you can't, repeat.
Tools for reading¶
A few things make reading 10× faster:
go doc <pkg> - show the documentation for a package, from your terminal.
pkg.go.dev - the web equivalent. Every Go package, indexed and rendered. The first place to look when you encounter an unfamiliar import.
Your editor's "Go to definition" / "Find references." In VS Code, right-click → "Go to Definition" jumps to where a function or type is defined. "Find All References" shows everywhere it's used. This is how you trace a name through a project quickly.
grep -r 'pattern' . - old-school but unbeatable. Find every place a string appears in the codebase.
go test -run TestName -v - run one specific test to watch it. Tests are the most reliable "what does this actually do?" diagnostic.
Reading the diff of a recent PR. GitHub's "Pull requests" tab shows what's changed lately. PRs are bite-sized - a few files, a clear description, a discussion. Often the best way to understand a project is to read its five most recent merged PRs.
A real session (worked example)¶
Let's read a tiny piece of a real Go project: the standard library's strings.Contains function. We're going to pretend this is a project we just opened.
Step 1: what does it do?
go doc strings.Contains from any terminal:
package strings // import "strings"
func Contains(s, substr string) bool
Contains reports whether substr is within s.
Clear: takes two strings, returns true if the second appears inside the first.
Step 2: where is it defined?
In your editor, command-click on strings.Contains in any of your own code. Or just look in your Go installation: $(go env GOROOT)/src/strings/strings.go. You'll see:
Two lines. It calls Index. So now we need to understand Index.
Step 3: follow one thread.
Go to definition on Index (or scroll up in the same file). Index is a few dozen lines, with some fast-path optimizations. Read the top comment, skim the body, don't try to understand the optimizations. Recognize: "it returns the position of the substring, or -1 if not found." That's enough.
Step 4: confirm with a test.
Open strings_test.go in the same folder. Search for "TestIndex" or "TestContains". Read a few test cases. Now you know - and have verified you know - what these functions do.
Step 5: write the one-line summary.
strings.Contains(s, substr) returns whether substr appears anywhere in s. Implemented in terms of strings.Index, which returns the position or -1.
That whole investigation took ~5 minutes. You did not understand every optimization in Index. That's fine. You understood enough to use it, recognize it in code, and find out more if you need to.
Things you will see that look scary¶
Real codebases use language features you haven't met yet. A few common ones, with "don't panic" notes:
context.Context- passed as the first parameter of most functions in real Go. Used for cancellation and deadlines. For reading: treat it as "the thing that tells me when to stop." We'll meet it in a later path.- Generics:
func Foo[T any](x T) T- Go added generics in 1.18.[T any]declares a type parameter. Reads as "a function that works on values of any type T." Don't worry about the rules; just read past it. interface{ ... }everywhere - interfaces declare behavior. As mentioned in page 11, a type satisfies an interface by having the right methods, with no explicit declaration. When you see a function take an interface, it works on anything matching.select { case ...:- likeswitchbut for channels. Waits until one of several channel operations is ready. Skip the details on first read; recognize it as "channel multiplexing."- Build tags:
//go:build linux- at the top of a file, restrict the file to certain platforms. Ignore unless you care about cross-platform builds. - Reflection:
reflect.Value,reflect.TypeOf- runtime type inspection. Powerful, slow, and used by frameworks (JSON encoding, ORMs). For reading: "this code does something dynamic with types." - Generated code - files starting with
// Code generated by ... DO NOT EDIT.are produced by a tool. Don't try to understand the implementation; read the intent (the input the generator used) instead.
You will hit things you don't recognize. That's normal even after years. The skill is knowing when to dig in and when to skim past. Most of the time: skim past. Dig in only when the thing matters to whatever you're actually trying to do.
Reading vs. understanding¶
There's a useful distinction:
- Reading code means following what it does, line by line. You can read code without understanding it deeply.
- Understanding code means knowing why it's shaped the way it is. You don't need to understand to contribute.
A first PR to a project often involves reading 1000 lines, understanding 100, modifying 5. That ratio is normal. Don't expect to understand the whole codebase before doing anything in it.
Exercise¶
No coding this time. Reading.
Pick a small Go project on GitHub. Three suggestions, ordered by smallness:
peterbourgon/ff- a small flag library (~2000 LOC). Clean code, well-organized.fatih/color- the colored-printing library you used in page 11. Small, single-purpose.urfave/cli(v2 or v3) - a CLI-application framework. Bigger (~10k LOC) but very well documented.
Pick one. Do the five-minute orientation:
- Read the README.
- List the top-level directories. What does the layout suggest?
- Open
go.mod. What does it depend on? (Probably almost nothing, for these.) - Find the entry point. Trace
main(or the most-public function) for 5 minutes. - Open the test file for the main code file. Pick three test cases; understand them.
Write a paragraph (in a note file, for yourself) answering: - What does this project do? - How is it organized? - What's the most interesting thing you noticed?
That paragraph is your start point for everything in pages 13-15.
What you might wonder¶
"What if I don't understand something even after reading it three times?" Write down what you don't understand, skip it, keep going. Come back later. Often the thing that confused you on page 1 makes sense after you've seen page 50. If it still doesn't, ask in the project's discussion forum or issue tracker - but only after you've tried for an hour yourself.
"What about huge projects like Kubernetes?" The same techniques work, just scaled. You won't ever read all of Kubernetes; nobody has. You'll learn one slice of it at a time. The orientation phase becomes "what does this sub-package do?" rather than "what does the whole project do?"
"How do I know which tests are 'representative'?"
The ones with the simplest names usually exercise the basic case. TestAddSimple, TestParseEmptyInput, etc. Start there. Save TestRaceConditionUnderContention for later.
Done¶
You can now:
- Apply a five-minute orientation to any new Go project.
- Use go doc, pkg.go.dev, and editor navigation to read code efficiently.
- Distinguish reading from understanding - and not require the latter to make progress.
- Recognize common "looks scary, isn't" patterns: generics, interfaces, context, generated code.
- Pick a small project and write a one-paragraph summary of what it does.
The skill on this page is the one separating "people who learned a language" from "people who can contribute to software." Practice it on three projects, not one.
Next page: how to choose a project worth your time, and what makes one "manageable" for a first contribution.
Next: Picking a project → 13-picking-a-project.md
13 - Picking a Project to Contribute To¶
What this session is¶
About 30 minutes plus your own browsing. You'll learn what makes a project a good first target, how to evaluate one in 10 minutes, and we'll list several real Go projects that consistently welcome new contributors.
Why the wrong project will burn you out¶
A first contribution to the wrong project goes like this:
- You pick something you use (Kubernetes, say).
- You spend three hours setting up the dev environment.
- You find a "good first issue" that hasn't been touched in six months.
- You spend two weeks understanding enough of the codebase to make a change.
- You submit a PR.
- Nobody reviews it for three weeks. Then a maintainer asks for changes you don't understand.
- You give up.
Every step in that story is normal. The fix isn't to be smarter; the fix is to pick a smaller, more responsive project first.
What "manageable" means¶
The criteria, in priority order:
- The project is small enough to comprehend. Under ~10k lines of Go is great for a first contribution. Under ~50k is doable. Above 100k, the orientation phase alone is a week.
- The maintainers are active. PRs get reviewed within a week, ideally a few days. Issues get responses.
- There are labeled "good first issue" or "help wanted" tickets. These are pre-screened to be approachable.
- There's a
CONTRIBUTING.md. Tells you the project's conventions - coding style, tests they expect, the PR process. - The tests run cleanly.
go test ./...from a fresh clone should pass. If it doesn't on a fresh checkout, that's a red flag about how careful the maintainers are. - You actually understand or care about what the project does. Bonus, but real - motivation matters when you're stuck.
How to evaluate a project in 10 minutes¶
Open the GitHub page. Check, in order:
| Signal | What you're looking for |
|---|---|
| Stars | More than ~100, less than ~50000. (Too few = abandoned, too many = crowded.) |
| Last commit date | Within the last month. Older = inactive. |
| Open PRs | Some, but not 200+. Look at how recent the most recent merged PR is. |
| PR merge time | Pick 3 recently merged PRs. How many days from open to merge? Under 14 is healthy. |
Open issues with good first issue label |
Filter the issue list. At least 5 is comfortable. |
| CONTRIBUTING.md | Exists and is readable. |
| CI status | A green ✓ on the main branch. Means tests pass. |
| Code of conduct | Means maintainers think about how contributors are treated. |
If a project fails on multiple of these, find another. There are thousands of Go projects on GitHub; you do not have to settle.
Several real candidates¶
These are Go projects that, as of 2026, have a track record of welcoming new contributors. Verify their current state with the 10-minute evaluation before you commit.
Tier 1: very small, very gentle¶
peterbourgon/ff- flag and config library. ~2-3k LOC. Tiny scope.fatih/color- terminal color library. ~1k LOC. One file does most of the work.spf13/pflag- POSIX-style flag library. ~5k LOC. Used bycobra,kubectl, and many others.stretchr/testify- testing assertions and mocks. ~10k LOC across many small subpackages. Pick one subpackage to focus on.
Tier 2: small to medium, well-organized¶
urfave/cli- CLI application framework. ~10k LOC. Excellent docs, clear architecture, responsive maintainers.go-chi/chi- HTTP router. ~5k LOC. Idiomatic Go HTTP code; great to read.pkg/errors- error wrapping (predecessor to standard library%w). Tiny. Still receives small contributions.magefile/mage- Make-replacement in Go. ~5k LOC.spf13/viper- config library. ~10k LOC. Heavily used, but has plenty of low-hanging issues.
Tier 3: larger, more visible¶
These are good targets after you've completed a contribution to a Tier 1 or 2 project.
hashicorp/terraform-provider-...- many small providers each manage one cloud service. Pick one whose service you've used.prometheus/...- multiple repos in the Prometheus monitoring ecosystem. Some are small enough to be Tier 2.grafana/grafanaorgrafana/loki- large, but with very labeled first issues.
Tier 4: massive - don't start here¶
kubernetes/kubernetes- the world. Wait until you have several other contributions.golang/go- Go itself. Takes months to land a first patch even for senior engineers. There are easier wins.
How to find issues¶
Once you've picked a project, visit its Issues tab.
Click "Labels." Filter by:
- good first issue
- help wanted
- documentation (often the easiest first contribution - a docs improvement is much less risky than a code change)
Read 5-10 issues. Look for one where: - The description is clear ("X happens when Y, expected Z"). - The fix is contained ("update this string", "add a test for..."). - Nobody has claimed it (no comment like "I'm working on this"). - It hasn't been open for a year (older = harder than it looks).
Add a comment: "I'd like to take this. Can you confirm it's still wanted?" Wait for the maintainer's reply. Don't start work until they confirm - claiming an issue without confirmation can step on someone else's toes.
What counts as a contribution¶
Don't underestimate small contributions. Real first contributions look like:
- Fixing a typo in the README.
- Adding a missing example in the documentation.
- Adding a test case for an existing function.
- Improving an error message to include more context.
- Removing a deprecated dependency.
- Fixing a small bug with a clear reproduction.
These are not "cheating." Every contribution is real, and every maintainer will tell you they prefer ten small clean PRs to one giant murky one. Your first PR's job is to get you through the workflow, not to rewrite the project.
Exercise¶
Pick a project. Evaluate three; commit to one.
-
Browse three projects from Tiers 1-2 above. For each, do the 10-minute evaluation. Write down the numbers (stars, last commit, recent PR merge time, count of
good first issues) in a notes file. -
Compare. Pick the one that scores best on responsiveness and has at least 3 unclaimed first issues.
-
Read its
CONTRIBUTING.mdend to end. Note any unusual requirements (e.g., signed commits, specific commit message format, dev container). -
Clone it locally:
-
Run its tests:
Confirm they pass. If they don't on a fresh clone, that's a red flag - you may want to pick a different project. -
Browse the open
good first issuetickets. Pick two candidates for your eventual contribution. Don't claim either yet. Read each carefully and decide which feels more contained.
You're not contributing anything yet. You're choosing the playing field. The choice matters more than people think.
What you might wonder¶
"What if I don't see a good first issue label?"
Some projects use other labels (help wanted, beginner-friendly, easy). Some don't label at all - in that case, look at recently closed PRs and see what kind of changes get merged. Documentation fixes are almost always welcome, even unlabeled.
"What if my favorite project is too big?"
Find a sub-project of it. The Kubernetes ecosystem includes dozens of smaller repos under kubernetes-sigs/ - many are Tier 2 in size. Same for prometheus/, grafana/, cncf/.
"What if I find a bug but there's no issue for it?" File one before submitting a PR. Describe what you saw, what you expected, how to reproduce. Wait for a maintainer to acknowledge it; sometimes the bug is intentional or already being worked on. Then say "I'd like to send a fix."
"I'm worried about being judged for asking a basic question on the issue." Three notes. (1) Most maintainers remember being new. (2) A polite, specific question is welcome. ("I tried X, expected Y, got Z" is much better than "doesn't work".) (3) A bad reception in the issue is itself useful information about the project. If you sense hostility, try a different project.
Done¶
You can now: - Articulate what makes a project a "good first target." - Run a 10-minute evaluation on any GitHub project. - Recognize project tiers and start at Tier 1 or 2. - Find issues that are appropriately sized for a first contribution. - Avoid the most common first-contribution traps (huge project, abandoned project, claiming without confirming).
You've chosen your target. The next page is the practical work of getting your local environment set up to work on it.
Next: Anatomy of a small OSS repo → 14-anatomy-of-a-small-oss-repo.md
14 - Anatomy of a Small OSS Repo¶
What this session is¶
About 45 minutes. We're going to walk through the file layout of a real (small) Go open-source project, file by file, so you know what every common piece is for. The next page asks you to make a contribution; this page makes the project feel less like a maze.
We'll use the standard Go project layout as our template, because most projects you'll meet are close variations of it. There's no official spec - but the conventions are stable enough that you can predict where things live.
A typical small Go project, from the top¶
After you git clone a repo and cd into it, you'll usually see something like:
.
├── README.md
├── LICENSE
├── CONTRIBUTING.md
├── CODE_OF_CONDUCT.md
├── go.mod
├── go.sum
├── Makefile (or justfile, or scripts/)
├── .github/
│ ├── workflows/ (GitHub Actions CI files)
│ └── ISSUE_TEMPLATE/
├── .gitignore
├── cmd/
│ └── mytool/
│ └── main.go
├── internal/
│ ├── foo/
│ │ ├── foo.go
│ │ └── foo_test.go
│ └── bar/
│ ├── bar.go
│ └── bar_test.go
├── pkg/ (sometimes; many projects skip this)
│ └── public/
│ └── public.go
├── docs/
│ ├── architecture.md
│ └── examples/
└── examples/
└── basic/
└── main.go
Not every project has all of these. Many have only a subset. The shape varies, but the roles of these files and folders are consistent.
What each piece is for¶
Root-level files¶
-
README.md- the project's homepage. Three things you want from it: one-line description, install instructions, smallest possible working example. If the README isn't useful, the project is incomplete. -
LICENSE- the legal terms (MIT, Apache 2.0, BSD, GPL, etc.). You should know what license the project uses before contributing. For most contributions this is a formality (you agree by submitting a PR), but for some projects (like Apache foundation projects) you'll need to sign a Contributor License Agreement (CLA). -
CONTRIBUTING.md- the most important file for you right now. Spells out how to propose changes, conventions to follow, branch naming, commit message style, how tests should look. Read it before doing anything. -
CODE_OF_CONDUCT.md- community standards. Usually a copy of the Contributor Covenant. You don't have to memorize it; just know that "be respectful, no harassment" is the gist. -
go.modandgo.sum- module definition and dependency checksums (page 11). Quick check: is this module's import path what you expected? What are the direct dependencies? -
Makefile- a script of common commands. Runmake helpif there's a help target, or just open the file and read the targets. Common ones:make build,make test,make lint,make clean. These often run with project-specific flags you'd get wrong from memory. -
.gitignore- files git should ignore. Mostly compiled binaries, IDE config, OS junk. You don't need to touch this.
.github/¶
GitHub-specific configuration:
-
workflows/- CI pipelines (YAML files). One file per workflow. Each defines triggers (push, PR, schedule) and jobs (build, test, lint, deploy). Reading these tells you what the project considers "the green path" - exactly which commands your PR will be measured against. -
ISSUE_TEMPLATE/- templates for filing different kinds of issue (bug report, feature request). When you file an issue, GitHub picks the template based on what you click. -
PULL_REQUEST_TEMPLATE.md- what GitHub pre-fills the PR description with. Usually includes a checklist ("tests pass", "docs updated", "no breaking changes"). Read it and follow it; reviewers expect every checkbox to be addressed. -
CODEOWNERS- who automatically gets assigned to review PRs touching a file. Useful for understanding who will read your PR.
cmd/¶
By convention, cmd/<name>/main.go is the entry point for a runnable program called <name>. If a project produces multiple binaries (e.g., a server and a CLI client), each gets its own folder under cmd/.
A main.go here typically is short - it parses flags, sets up a config, calls into internal/ or pkg/ to do the real work, handles signals, exits. Long main.go files are a smell; the work lives elsewhere.
internal/¶
The magic folder. Go's compiler enforces: packages under internal/ can only be imported from within the same module. So internal/foo is private to this project; nobody else can import it. This is how libraries hide their implementation details from users.
Most of the actual code lives in internal/. It's where you'll spend most of your reading time. The subdirectories under internal/ are usually organized by responsibility (internal/server, internal/storage, internal/auth) - read the names to get a mental map.
pkg/¶
Public reusable packages - code that other projects can import. Many projects don't use pkg/ and put public code at the top level instead. There's no rule.
For a CLI tool with no public API, you may not see pkg/. For a library, the public API is at the top level or in pkg/<name>/.
docs/¶
Project documentation beyond the README. Architecture overviews, design decisions, runbooks. If you want to understand why the project is shaped the way it is, this is where to look.
examples/ or _examples/¶
Runnable example code showing how to use the project. Underscore prefix (_examples/) tells the Go tools to ignore it (so go build ./... doesn't try to compile examples). Read these - they show you the "official" way to use the project.
vendor/¶
A copy of the project's dependencies, committed into the repo. Common before Go modules; less common now. If a vendor/ exists, the project does vendored builds - you build against vendor/ instead of downloading dependencies fresh. Run go build -mod=vendor.
testdata/¶
By Go convention, any folder named testdata is ignored by the build system. Used to store input files for tests (sample JSON, fixture databases, etc.). You'll see this scattered through projects with significant tests.
A worked walkthrough: peterbourgon/ff¶
Let's apply the above to a real, small project: peterbourgon/ff, a flag and config library. Clone it:
Look at the top-level structure:
You should see something close to:
README.md LICENSE go.mod go.sum
ff.go ff_test.go
parse.go parse_test.go
testdata/
ffcli/ ffyaml/ fftest/ ffjson/ fftoml/ ...
.github/
Apply what you just learned:
- README.md - read it. What does
ffdo? (A flag and configuration parser.) go.mod- what's the module path? (github.com/peterbourgon/ff/v3.) Any dependencies? (Almost none - that's a quality signal.)- No
cmd/- meaning this is a library, not a runnable program. - No
internal/- meaning everything here is part of the public API or extensible. ff.go,parse.go- the core. Open them.*_test.go- tests right next to the code. Standard Go layout.testdata/- fixtures for tests. Open one to see what kind of data the tests use.ffyaml/,ffjson/,fftoml/- subpackages adding YAML/JSON/TOML config support. Each is independently importable..github/workflows/- what's in the CI? Open the workflow YAML. It probably runsgo test ./...on several Go versions.
Five minutes later, you have a map. You haven't read the implementation; you don't need to. You know what's there.
The conventions in CONTRIBUTING.md¶
Open the CONTRIBUTING.md (if one exists) and look for:
- Branch naming. Some projects expect
fix/issue-123or similar. - Commit message format. Some require Conventional Commits (
feat: add X,fix: handle Y). Many don't care. - PR description. What sections are expected? (Summary, motivation, testing.)
- Sign-off / DCO. Some projects require commits to be signed with
git commit -s(addsSigned-off-by: ...to your commit). The Linux kernel and many CNCF projects require this. - Test expectations. Often: "all new code must have tests, and
go test ./...must pass." - Linting. Often:
golangci-lint runmust be clean. Install it if mentioned.
These conventions are not annoying gatekeeping; they're what makes a project run smoothly with hundreds of contributors. Follow them; the maintainers will be relieved.
Exercise¶
Use the project you picked in page 13.
- Clone it locally.
- Walk the layout, file by file, applying the categories above. Write down where each piece lives.
- Read the CONTRIBUTING.md end to end. Note any unusual requirements.
- Open one CI workflow YAML in
.github/workflows/. Identify: what commands does CI run? On what platforms? Against what Go versions? - Run those CI commands locally (
go test ./...,golangci-lint run, whatever the workflow does). Confirm they pass on a fresh clone. - Open the issue you tentatively picked. Identify the three files most likely to be involved in the fix. (You don't have to be right - just guess based on file names and a quick
grep.)
That's everything you need to make a change. The next page walks through actually doing it.
What you might wonder¶
"What if a project doesn't follow the standard layout?"
Some don't. Read the README and any ARCHITECTURE.md; they'll explain the layout. If neither exists, fall back to: "follow main.go and see where it leads."
"What's the difference between pkg/foo/ and just foo/?"
Convention only. pkg/foo/ was popular for a while; the Go team's official "Standards" page doesn't endorse it. Many high-profile projects (like cobra, viper) don't use pkg/.
"What's a 'go generate' file?"
Sometimes Go projects use code generators (for protobuf bindings, mocks, embedded files). A line //go:generate ... near the top of a file declares a generation command. go generate ./... runs them all. Generated files usually have a // Code generated ... DO NOT EDIT. header - don't edit them; regenerate them.
"What if CI is breaking on main when I clone?"
A red flag about the project's health. Either the project is in transition (a big refactor mid-flight) or maintainers aren't watching closely. Reconsider whether this is the right first project; if the bar to landing a PR includes "first I have to fix CI," that's too much for a first contribution.
Done¶
You can now:
- Recognize the typical Go project layout.
- Locate every common file/folder by role (cmd, internal, pkg, docs, etc.).
- Read a CONTRIBUTING.md for conventions you'll need to follow.
- Read CI workflows to know exactly what your PR will be measured against.
- Make a confident guess at which files a given change will touch.
You're ready to actually do the thing.
Next: Your first contribution → 15-your-first-contribution.md
15 - Your First Contribution¶
What this session is¶
The whole thing. Maybe two sessions. We're going to walk through the workflow of making a real contribution to a real open-source project, end to end: fork, branch, change, test, push, PR, review, merge. This is the page the whole path has been building toward.
By the end you'll have submitted a pull request. By the time it merges (which may be days or weeks later), you'll be an open-source contributor - a small, real one. Welcome.
The whole workflow at a glance¶
The standard GitHub contribution flow, in eight steps:
- Fork the project on GitHub (a personal copy).
- Clone your fork to your machine.
- Add upstream as a remote (so you can pull future updates).
- Branch off
main. - Change the code; add a test.
- Run tests locally; make sure CI's command passes.
- Push to your fork.
- Open a PR against the upstream project.
Each step is short. The whole sequence might take 20 minutes the first time you've practiced it; 5 minutes once it's automatic.
Step 1: Fork¶
On the project's GitHub page, click Fork (top right). GitHub creates a copy at github.com/<you>/<project>. This is your personal copy; you can push anything to it without affecting the original (called upstream).
Step 2: Clone¶
In your terminal:
You now have a local copy of your fork.
Step 3: Add upstream as a remote¶
You want to be able to pull updates from the original project, not just your fork. Add it as a remote called upstream:
Check it worked:
You should see two remotes: origin (your fork) and upstream (the original).
Whenever you want to update your local copy with the latest from upstream:
(There are slicker workflows, but this is the most explicit and hardest to mess up.)
Step 4: Branch¶
Never commit directly to main. Always create a branch for your change. Common naming:
The name should hint at what the change does. Some projects have conventions in CONTRIBUTING.md; follow them.
Step 5: Make the change¶
Edit the file(s) involved in your issue. The change should be:
- Small. Touch as few files and lines as possible. A 5-line diff is easier to review than a 500-line one.
- Focused. One issue per PR. Don't bundle unrelated fixes; submit them as separate PRs.
- Tested. If the project has tests and your change has any logic, add a test. Even one is enough for a first contribution.
For code changes, follow the project's existing style. If function names are CamelCase in this project, don't introduce snake_case. If error messages start lowercase (Go convention), match it. The reviewer's job is to evaluate your change, not teach you the style.
Run gofmt -w . (or goimports -w .) on any file you've edited. This auto-formats to Go's standard layout. Most editors do it on save; do it manually if not.
Step 6: Run tests locally¶
Run exactly what CI runs. You looked at the CI workflow in page 14; replay those commands here:
go test ./... # all tests
go test -race ./... # with race detector, if the project uses goroutines
golangci-lint run # if the project uses it
Every command should be green. If a test fails, fix it before pushing. If lint complains, fix it before pushing. Pushing red CI is rude - it makes reviewers babysit your PR through cycles of "now please fix this, now please fix that."
Don't trust "it works on my machine." Run the exact commands the maintainers will run.
Step 7: Commit and push¶
Stage and commit your change:
The commit message: - First line, short. ~50 chars. Imperative mood ("Add", "Fix", not "Added" / "Fixed"). - Optional body. Blank line, then a longer description. - Reference the issue. "#123" links the PR to the issue automatically on GitHub.
If CONTRIBUTING.md mandates a format (Conventional Commits: feat:, fix:, chore:...), follow it.
If CONTRIBUTING.md requires DCO (Developer Certificate of Origin):
The -s adds Signed-off-by: Your Name <your@email> to the commit, certifying you wrote the change (or have rights to contribute it).
Push to your fork:
GitHub will print a URL - clicking it opens a pre-filled "open a pull request" page.
Step 8: Open the PR¶
On the upstream project's GitHub page, you should now see a banner suggesting "Compare & pull request." Click it.
Fill out the PR:
- Title. Mirror the commit message. Or, if you used the issue's title, match that.
- Description. What does this change? Why? What did you test? Reference the issue: "Closes #123" or "Fixes #123" - GitHub will auto-close the issue when this PR merges.
- Checklist. If the PR template has one, address every item.
Submit. CI will start. Wait for it to go green; if it goes red, look at the failing step in the GitHub UI and fix locally, then push more commits to your branch (they automatically attach to the PR).
What happens next: review¶
A maintainer will look at your PR. The possible outcomes:
- "LGTM, merging." Best case. Done.
- "Could you make these changes?" Most common. They'll leave inline comments. Address each one - either by changing the code or replying with a reason not to. Then push more commits.
- "Thanks, but we don't want this." Rare for
good first issuework, but possible if the issue was stale or you misread the requirement. Don't take it personally. Ask if there's a related issue you could pick up instead. - Silence. Sometimes happens. After a week of nothing, leave a polite comment: "Friendly bump - anything I should address?"
Code review is not personal. Even senior engineers get review comments. The skill is address the feedback efficiently without arguing about style preferences. Disagree only on substance, and only with reason.
After the merge¶
When your PR is merged:
- Update your fork's
main. Pull from upstream, push to your fork (the workflow from step 3). - Delete the branch. Locally (
git branch -d fix/issue-123-...) and on your fork (git push origin --delete fix/issue-123-...). - Take a screenshot. Yes, really. You'll be glad to have it.
- Don't immediately pick the next issue. Sit with the experience for a day. Re-read the merged code, the review comments, the discussion. The learning is in the loop, not the artifact.
A copy-paste sequence (template)¶
Here's the full sequence, copy-paste-able, for a small docs fix on a project called example-org/example-repo for issue #42:
# 1-2. Fork on GitHub, then clone:
git clone https://github.com/<you>/example-repo
cd example-repo
# 3. Add upstream:
git remote add upstream https://github.com/example-org/example-repo
git fetch upstream
# 4. Branch:
git checkout -b docs/fix-typo-in-readme
# 5. Make the change. Edit README.md.
# 6. Run tests (the project's CI commands):
go test ./...
golangci-lint run
# 7. Commit and push:
git add README.md
git commit -m "docs: fix typo in installation section (#42)"
git push origin docs/fix-typo-in-readme
# 8. Open the PR on github.com. Wait. Respond to review.
That's it. The whole thing.
After your first contribution: what next¶
Once you've landed one PR:
- Pick another issue in the same project. Familiarity compounds; your second PR will be much faster.
- After 3-5 PRs in one project, consider becoming a regular. Watch the issue tracker. Respond to issues you can answer. Review other people's PRs (you don't need to be a maintainer to leave helpful comments).
- Branch out. Use what you learned to evaluate and contribute to a Tier 2 or Tier 3 project from page 13.
- Build something of your own. Use Go to scratch a personal itch. Publish it. Iterate based on real use.
- Read this curriculum's "Go Mastery" path when you want to go from "I can contribute" to "I can architect and review."
What you might wonder¶
"What if my PR sits unreviewed for weeks?" Leave a polite check-in comment after ~1 week of silence. After 3 weeks of silence, ask in any community channel the project has (Discord, Slack, mailing list) whether you should redirect the work elsewhere. Some projects are slow; some are abandoned.
"What if a maintainer is rude?" Disengage, contribute elsewhere. There are thousands of projects. You don't owe any single project your time.
"What if I disagree with a code-review comment?" Two questions to ask yourself: (1) Is the comment about correctness, or style? Style: just do what they ask. Correctness: explain your reasoning, with a specific example. (2) Is the maintainer obviously more experienced with this codebase than you? Yes: they're probably right and you should learn from it. No: it's reasonable to push back. Either way, stay polite, stay specific.
"What if I can't make the tests pass locally?" Read the CONTRIBUTING.md for any setup steps you missed. Check the CI workflow for env variables or build flags. If still stuck, ask in the issue or PR - but be specific about what you tried.
"What if I introduced a bug in my fix?" Comes up. Push another commit fixing it. Don't squash or rewrite history unless asked - many projects squash on merge automatically.
"Can I list this on my CV?" Yes. "Open-source contributor, projects: X, Y, Z" is a real signal. Link to your specific merged PRs, not just the repos.
Done¶
You can now: - Walk through the full GitHub contribution workflow: fork → clone → branch → change → push → PR. - Run a project's CI commands locally before pushing. - Write a contribution-ready commit message. - Read and address code-review feedback. - Recover gracefully when a PR sits or gets pushback.
Done with the path¶
You started this path being told that programming was something you could learn from scratch. You've now:
- Installed Go and written your first program.
- Learned every fundamental Go concept: variables, types, control flow, functions, structs, methods, slices, maps, errors, pointers, concurrency, tests, packages, modules.
- Read a real Go open-source project and made sense of its layout.
- Picked a project, prepared a change, submitted a pull request.
What you should not do next: feel like you "know Go" now. You know what you've been taught. There is much more - networking, advanced concurrency, the JIT, the runtime, performance work, large-system design. Each is a path of its own.
What you should do: keep contributing. The way you become an engineer is by doing real work on real codebases over time. There is no shortcut.
Two recommended next paths if you want to keep going on this site:
- Go Mastery - the 24-week deep dive into the Go runtime, scheduler, GC, distributed systems. Assumes you're past where this path leaves you.
- Linux Kernel - Go programs run on a Linux kernel; understanding how it works underneath makes you a much better Go engineer.
Or just go build something. Programming pays you back when you build, not when you read.
Congratulations. You are no longer a beginner.