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 - Structs, Methods, and Interfaces¶
What this session is¶
A long one - about ninety minutes. You'll learn the three constructs that make Go feel like Go: structs (group related data), methods (functions attached to a type), and interfaces (a contract that says "if your type has these methods, you can use it anywhere this kind of thing is expected"). These three together are what people mean when they say Go is "lightweight object-oriented" - there are no classes, no inheritance, but you can still model real things and write code that's flexible and testable.
This chapter is denser than the others. If it feels like a lot, take the main flow first; the Going deeper section at the end is for after you've used the material once or twice.
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.
Value vs pointer receivers (the mental model)¶
There's a second form of receiver, with a * in front of the type:
This is a pointer receiver. The previous one (func (p Point) ...) is a value receiver.
The simple rule: a value receiver gets a copy; a pointer receiver gets the original.
Why this matters - concrete demo:
type Counter struct{ Value int }
func (c Counter) IncByValue() { c.Value++ } // operates on a copy
func (c *Counter) IncByPointer() { c.Value++ } // operates on the original
func main() {
c := Counter{}
c.IncByValue()
c.IncByValue()
fmt.Println(c.Value) // 0 - both increments mutated a copy and threw it away
c.IncByPointer()
c.IncByPointer()
fmt.Println(c.Value) // 2 - both increments wrote through to c
}
You'll meet pointers in full on page 08. For now, the practical rules of thumb:
- If the method changes a field of the receiver → use
*Type. Otherwise the change won't stick. - If the receiver is a big struct (lots of fields, or fields that are themselves big) → use
*Typeeven if you don't modify it. A pointer is one machine word; copying the whole struct would be wasted work. - If the receiver is small and immutable → either works. Pick one and use it for all methods on that type - consistency matters more than the choice itself.
When in doubt: use *Type. Real Go code uses pointer receivers around 80% of the time.
A bit of magic that makes this nicer: when you call c.IncByPointer() and c is a regular value (not a pointer), Go silently rewrites the call to (&c).IncByPointer() for you. The & is inserted automatically if c is addressable - that is, if it has a name (a variable, a struct field of a variable, an array element of a variable). This is why both c.IncByValue() and c.IncByPointer() look the same at the call site, even though the second technically requires a pointer. There's a corner case where this doesn't work - when the value is non-addressable, like a struct returned by a function - but you'll meet that rarely.
Try it: 1. Type the Counter example. Confirm the output is 0 then 2.
-
Try
Counter{Value: 5}.IncByPointer()directly. Compile error: "cannot take the address ofCounter{Value: 5}" - the struct literal has no name, so it's non-addressable. You can't take its address, so the auto-&insertion can't happen. Assign it to a variable first. -
Change
IncByValueto alsoreturn c(so it returns the copy after incrementing). Callc = c.IncByValue(). Now the change sticks - because you're explicitly assigning the returned copy back. This is howappendworks, by the way.
Methods on non-struct types¶
Methods aren't only for structs. You can define a method on any named type declared in the same package - including a named alias of int, string, float64, even a function type.
package main
import "fmt"
type ByteSize float64
const (
_ = iota
KB ByteSize = 1 << (10 * iota)
MB
GB
)
func (b ByteSize) String() string {
switch {
case b >= GB:
return fmt.Sprintf("%.2f GB", b/GB)
case b >= MB:
return fmt.Sprintf("%.2f MB", b/MB)
case b >= KB:
return fmt.Sprintf("%.2f KB", b/KB)
}
return fmt.Sprintf("%d B", int(b))
}
func main() {
var size ByteSize = 5_000_000
fmt.Println(size) // "4.77 MB"
}
That's a lot of new things in one example. Let's walk through it line by line so none of it is magic.
type ByteSize float64 - defines a brand-new type that has the same underlying representation as float64 but is a distinct type from the compiler's point of view. You can't add a ByteSize to a float64 without an explicit conversion - the compiler treats them as separate, even though they're the same shape in memory. This is exactly what lets us attach a method to it: methods can only be defined on named types declared in the current package, and float64 doesn't qualify (it's a built-in).
What is iota? Inside a const ( ... ) block, iota is a special built-in identifier that starts at 0 on the first line and increments by 1 on every subsequent line. That's the whole rule. So:
It's just a line-numbered counter, scoped to one const block. The first time iota shows up in a fresh const block, it's 0; the next line, 1; and so on. Go doesn't have an enum keyword - iota is how you build one.
Why is the first line _ = iota? The _ (blank identifier) means "throw this value away." We're consuming the line iota = 0 deliberately, because we don't want KB to be on the first line - if KB were on the first line, its calculation would use iota = 0, and we'll see in a second why that would give us the wrong number.
What does 1 << (10 * iota) actually compute? << is the bit-shift left operator. 1 << n means "take the number 1 and shift its bit n places to the left," which (for non-negative n) is exactly the same as 2^n. So:
1 << 10is2^10which is1024.1 << 20is2^20which is1,048,576(about 1 million).1 << 30is2^30which is1,073,741,824(about 1 billion).
Those three numbers are exactly 1 KB, 1 MB, and 1 GB in binary units (the "real" units a computer uses, as opposed to the decimal 1,000 / 1,000,000 / 1,000,000,000 that hard-drive marketing uses). Now apply the iota counter:
- Line
_ = iota->iota = 0. Value discarded. (If we had writtenKB = 1 << (10 * 0)here we'd getKB = 1, notKB = 1024- that's the line we wanted to skip.) - Line
KB ByteSize = 1 << (10 * iota)->iota = 1, soKB = 1 << 10 = 1024. - Line
MB->iota = 2, soMB = 1 << 20 = 1,048,576. - Line
GB->iota = 3, soGB = 1 << 30 = 1,073,741,824.
Wait - MB and GB don't have an = sign. How does Go know what they equal? A second Go rule for const blocks: if a line omits its right-hand side, Go silently repeats the previous line's expression (verbatim - including the iota reference, which has advanced). So MB re-uses 1 << (10 * iota) with iota now equal to 2, and GB re-uses it again with iota = 3. This "repeat the previous expression" rule is why iota-based enums in Go are so compact: you write the formula once on the first real line, and every subsequent line silently re-applies it with a fresh iota.
Three rules, working together: (1) iota counts lines in a const block; (2) 1 << n is 2^n; (3) an omitted RHS repeats the previous expression. That's the entire trick - no magic.
The String() string method. A method named exactly String with signature () string is special: it makes the type satisfy a built-in interface called fmt.Stringer. We haven't talked about interfaces yet (next section!), but here's the punchline now: whenever you pass a value to fmt.Println, fmt.Printf("%v", ...), or any of the fmt family, the package checks at runtime "does this value have a String() method?" If yes, it calls that method to get the printable form. If no, it falls back to the default formatting.
That's why fmt.Println(size) prints "4.77 MB" and not the raw float 4.76837158203125e+06: size is a ByteSize, ByteSize has a String() method, so Println calls it and prints whatever it returns.
And the "4.77 MB" itself? size is 5,000,000. The switch in String() checks: is 5,000,000 >= GB (about a billion)? No. Is it >= MB (about a million)? Yes. So we return fmt.Sprintf("%.2f MB", b/MB) -> 5,000,000 / 1,048,576 = 4.768... -> formatted as "4.77 MB". The division is real number division because ByteSize is float64 underneath, which is why we get .77 and not just an integer.
You can even define methods on a function type - Go's standard library uses this pattern in http.HandlerFunc:
type HandlerFunc func(http.ResponseWriter, *http.Request)
func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f(w, r)
}
A HandlerFunc is a function, but it also has a method (ServeHTTP). Why? So a plain function can be passed wherever an http.Handler interface is expected. We'll see the trick in detail in Going deeper.
The only restriction: the receiver's base type must be defined in the same package as the method. You can't add methods to int (a built-in) or to types from other packages (time.Time, bytes.Buffer, etc.). Define your own named type if you want to extend something.
Try it:
-
Run the
ByteSizeexample. Try printingByteSize(500),ByteSize(2_000_000_000). The output adapts to scale. -
Try
var size ByteSize = 1.5; var f float64 = size. Compile error: cannot usesize(typeByteSize) as typefloat64. You need an explicit conversion:f := float64(size). The distinct-type rule has teeth. -
Define
type WordCount intwith a methodPlural() stringthat returns"words"if the count != 1 and"word"otherwise. Use it:fmt.Println(WordCount(3).Plural()).
Interfaces: the contract¶
Here's the question interfaces solve: how do you write a function that works with anything that can do a certain thing, without knowing what that thing is?
Example. You want a function that writes some text "somewhere." Maybe a file. Maybe the terminal. Maybe a network connection. Maybe a memory buffer (for testing). They're all completely different concrete types, but they all support the operation "write some bytes." You want one function that takes any of them.
That's an interface. An interface is a contract that says "anything that has these methods can be used here."
package main
import "fmt"
// Any type with a Bark() method satisfies this interface.
type Barker interface {
Bark() string
}
type Dog struct{ Name string }
func (d Dog) Bark() string { return d.Name + " says woof!" }
type Fox struct{ Name string }
func (f Fox) Bark() string { return f.Name + " says ???" }
// makeNoise accepts ANY type that satisfies Barker.
func makeNoise(b Barker) {
fmt.Println(b.Bark())
}
func main() {
makeNoise(Dog{Name: "Rex"}) // Rex says woof!
makeNoise(Fox{Name: "Renard"}) // Renard says ???
}
Barker is a contract: "you can use anything as long as it has a Bark() string method." Dog has one. Fox has one. Both can be passed to makeNoise. makeNoise doesn't know or care about the concrete type - it just calls .Bark().
That's it. That's the whole idea. An interface is a behavior specification, and any type with the right methods automatically satisfies it.
Two pieces of syntax:
type Barker interface { Bark() string }- declares the interface type, listing the methods it requires.func makeNoise(b Barker)- usesBarkerlike any other type. Inside the function,bis an interface value; only the methods listed in the interface are available on it.
The one-method -er naming convention is universal in Go: io.Reader, io.Writer, fmt.Stringer, sort.Interface, error. When an interface has one method, name the interface after the method plus -er.
Try it: 1. Type the example. Confirm it prints both lines.
-
Add a
type Cat struct{ Name string }without aBark()method. TrymakeNoise(Cat{Name: "Whiskers"}). Compile error: "Cat does not implement Barker (missing Bark method)". The compiler checks at compile time, before your program runs. -
Add a
func (c Cat) Bark() string { return c.Name + " meows" }. NowmakeNoise(Cat{...})works. The cat satisfiesBarkerpurely because it has the right method - even though we never wrote anywhere thatCat"is a"Barker.
Implicit satisfaction: the Go way¶
The previous example showed something quietly profound: Cat satisfied Barker automatically. We never wrote class Cat implements Barker (Java), nor class Cat : public Barker (C++). The Cat type and the Barker interface might even be in different packages, written by different people, who never heard of each other.
This is called implicit interface satisfaction, and it's the central Go design choice for code reuse. The rule:
A type satisfies an interface as soon as it has all the methods the interface requires. No declaration is needed.
Three big consequences:
1. You can define an interface for a type you didn't write. Maybe you import a package with a type pdf.Document that has a Title() string method. You can define your own type Titled interface { Title() string } and use it to accept pdf.Document (and anything else with a Title() string). You don't need access to the original code.
2. You can add a new method to your own type and it automatically starts satisfying every interface that wants that method. The connections are made by the compiler at the points where you assign a concrete type to an interface variable.
3. Small interfaces become normal. Because you don't have to design hierarchies up front, real Go code is dotted with tiny one- or two-method interfaces, each precise about what it requires. io.Reader has one method. error has one method. fmt.Stringer has one method. The standard library's most flexible types satisfy dozens of different one-method interfaces.
Contrast with how you'd write this in Java:
// Java: every type that wants to be Barker must explicitly declare it.
public interface Barker {
String bark();
}
public class Dog implements Barker { // explicit "implements"
public String bark() { return "woof"; }
}
In Java, Dog must know about Barker at the moment it's defined. In Go, the relationship goes the other way: Barker (or any future interface) can discover Dog later, just by needing a method Dog happens to have. The decoupling is the win.
Try it: 1. Look at the standard library's fmt.Stringer:
String() string method satisfies it. You already defined one - ByteSize has String(). So ByteSize satisfies fmt.Stringer automatically - even though ByteSize lives in your package and Stringer lives in fmt. -
That's how
fmt.Println(size)printed"4.77 MB"earlier. Thefmtpackage internally doesif s, ok := v.(Stringer); ok { return s.String() }. YourByteSize.String()got called becauseByteSizehappens to satisfyStringer. -
The
errortype you've been using is also an interface:type error interface { Error() string }. Same mechanism. Any type withError() stringis an error.
A practical example: fmt.Stringer¶
fmt.Stringer is the most common interface a beginner runs into. It's used by the fmt package to ask types "how do you want to be printed?" Implement it once, and fmt.Println, fmt.Printf with %v, fmt.Sprintf, and string interpolation all use your version.
package main
import "fmt"
type Money struct {
Amount int64 // in cents
Currency string
}
// By giving Money a String() method, we satisfy fmt.Stringer.
// fmt.Println(m) will now call this method.
func (m Money) String() string {
return fmt.Sprintf("%s %.2f", m.Currency, float64(m.Amount)/100)
}
func main() {
price := Money{Amount: 1999, Currency: "USD"}
fmt.Println(price) // USD 19.99
fmt.Printf("paid %v\n", price) // paid USD 19.99
}
We never imported fmt.Stringer. We never wrote Money implements Stringer. We just defined a method with the right name and signature, and the fmt package's runtime check found us. This is implicit satisfaction at work.
Try it: 1. Run the example. Confirm both prints show USD 19.99.
-
Comment out the
String()method. Re-run. Nowfmt.Println(price)prints{1999 USD}- the default struct formatting. Thefmtpackage looked forStringer, didn't find it, fell back. -
Define
type Username stringwith aString()method that returns"@" + string(u). Print one. Notice: aUsernameis astringunderneath butStringercontrols how it prints.
The empty interface and any¶
There's a special interface that requires no methods:
Since every type has zero or more methods, every type satisfies interface{}. It's the type "anything at all." You'll see it used when a function needs to accept truly any value:
Println takes any number of arguments of any type - int, string, your Money struct, all mixed together. It works internally by inspecting the dynamic type of each argument at runtime (see Going deeper for how).
any is what Go uses where other languages might say Object or void *. Two things to know:
-
Functions that take
anylose all type information at the function boundary. Inside, you have to use type assertions or type switches (next section) to get back to a concrete type before you can do anything useful. -
Prefer specific interfaces over
any.func writeTo(w io.Writer)is much better thanfunc writeTo(x any)- the first tells the reader (and the compiler) what's expected. Reach foranyonly when you genuinely accept anything.
Try it:
-
Define
func describe(v any) { fmt.Printf("type=%T value=%v\n", v, v) }. Call it with anint, astring, and yourMoneystruct. The%Tverb prints the dynamic type. -
Note that you can't do
v + 1insidedescribe- even if you pass anint. The compiler doesn't know it's anintonce it's in aanyslot. You'd have to assert (next section).
Type assertions and type switches¶
When you have an interface value and you want to get back to the concrete type underneath, you use a type assertion:
var v any = "hello"
s := v.(string) // type assertion: "treat v as a string"
fmt.Println(s, len(s)) // hello 5
v.(string) reads "give me v's underlying value, asserting it's actually a string." If v really is a string, you get it. If it isn't, the program panics.
The safer comma-ok form doesn't panic:
You've seen this shape before - same pattern as m["key"] returning (value, ok) on maps. The "comma ok" form is everywhere in Go.
For deciding among several types, use a type switch:
func describe(v any) {
switch x := v.(type) {
case nil:
fmt.Println("it's nil")
case int:
fmt.Println("an int:", x*2)
case string:
fmt.Println("a string of length", len(x))
case fmt.Stringer:
fmt.Println("something stringer-able:", x.String())
default:
fmt.Printf("don't know: type=%T value=%v\n", v, v)
}
}
Read the syntax carefully: switch x := v.(type) is special. The .(type) keyword (not a real type - a contextual keyword) is only legal inside a type switch. Each case is a type; inside that case, x has that type and can be used as such. case int: makes x an int; case fmt.Stringer: makes x a fmt.Stringer.
This is how fmt.Println actually dispatches: a type switch that checks for Stringer, error, []byte, and many others in some order.
Try it: 1. Type the describe example. Call it with 42, "hi", your Money, and nil. Each case fires. 2. The order of cases matters when types overlap. Try putting case fmt.Stringer: before case int:. What happens with describe(42)? (Hint: int doesn't have a String() method, so it's still not a Stringer. But other types might match earlier than you expect.) 3. Add case []int: and case map[string]int:. Test with appropriate values. Type switches handle complex types just fine.
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.
Going deeper¶
The basics above are enough to use methods and interfaces effectively. The material below is for when you want the precise model - the rules, the runtime mechanics, the famous gotchas, and the idiomatic patterns mature Go developers use. Drawn from the Go spec, Effective Go, and Russ Cox's writing on the runtime. Links at the end.
Method sets: the precise rule (and why *T satisfies more interfaces than T)¶
You met the rule informally above. The exact statement from the Go spec:
- The method set of
Tis the set of methods declared with receiverT. - The method set of
*Tis the set of methods declared with receiverTor*T.
In other words: pointer types include value methods, but value types do not include pointer methods.
That asymmetry matters for interface satisfaction. Consider:
type Speaker interface { Speak() string }
type Cat struct{ Name string }
func (c *Cat) Speak() string { return c.Name + " says meow" } // pointer receiver
Cat's method set is empty (no methods with receiver Cat). *Cat's method set contains Speak. So:
var s Speaker
s = &Cat{Name: "Whiskers"} // OK - *Cat satisfies Speaker
s = Cat{Name: "Whiskers"} // COMPILE ERROR - Cat does not satisfy Speaker
// (Speak has pointer receiver)
If you had defined Speak with a value receiver (func (c Cat) Speak()...), both forms would work, because the method would be in both Cat's and *Cat's method set. But once a single method on a type uses a pointer receiver, you've effectively decided that only *T values can satisfy interfaces requiring that method.
Practical advice: within a single type, stick to one receiver style for all methods - either all value or all pointer. Mixing causes confusing interface-satisfaction puzzles and inconsistent behavior.
Try it: Define type Counter struct{ Value int } with a value-receiver Get() int and a pointer-receiver Inc(). Try assigning both Counter{} and &Counter{} to a Speaker interface (after adding a Speak method with a value receiver). Notice that &Counter satisfies any interface either method-set holds; bare Counter only satisfies interfaces in its smaller method set.
Method values vs method expressions¶
Two ways to refer to a method without immediately calling it.
A method value is a method bound to a specific receiver:
p := Point{X: 3, Y: 4}
dist := p.DistanceFromOrigin // method value: a function bound to p
fmt.Println(dist()) // 5
dist is a function value of type func() float64. The receiver is captured at the moment of binding. Even if p changes later, dist() still operates on the original p it was bound to (for value receivers; pointer-receiver method values capture the pointer, so they follow mutations to what's behind the pointer).
A method expression is a method as a regular function with the receiver as an explicit first parameter:
dist := Point.DistanceFromOrigin // method expression: a free function
fmt.Println(dist(Point{X: 3, Y: 4})) // 5
dist here has type func(Point) float64 - the receiver is now just a regular first argument.
You'll see both forms occasionally in real code. Method values are common (passing obj.Method to a function that takes a callback); method expressions are rarer (mostly for reflection-style code or generic helpers).
Try it: 1. Method value with a value receiver:
p := Point{X: 3, Y: 4}
d := p.DistanceFromOrigin
p.X = 99
fmt.Println(d()) // still 5 - d captured a copy of p
- Method value with a pointer receiver: define
func (p *Point) Magnitude() float64 { ... }. Bindm := pp.Magnitudewherepp := &Point{X: 3, Y: 4}. Mutatepp.X = 99. Callm(). The result changes - becausemcaptured the pointer.
Embedded types and method promotion¶
Go has no inheritance. It has something subtler - embedding - which mostly does the job without the pitfalls.
type Logger struct {
prefix string
}
func (l *Logger) Log(msg string) {
fmt.Println(l.prefix + ": " + msg)
}
// Job embeds *Logger as an anonymous field. Logger's methods are "promoted"
// to Job - callers can do job.Log() as if Job had defined Log itself.
type Job struct {
*Logger // anonymous (embedded) field
Command string
}
func main() {
j := &Job{
Logger: &Logger{prefix: "Job"},
Command: "build",
}
j.Log("starting") // calls Logger.Log via promotion
// prints: Job: starting
}
The key bits:
*LoggerinsideJobwith no field name is called an anonymous (or embedded) field.- Methods on the embedded type are promoted: callers can do
j.Log(...)exactly as ifLogwere defined onJob. - This is composition, not inheritance.
Jobhas-aLogger; it does not is-aLogger. There's no class hierarchy, no overriding semantics - just method promotion.
You can "override" by defining a method on Job with the same name; that wins over the promoted one. But it's not polymorphism in the Java/C++ sense - the embedded Logger's code only ever calls Logger's methods, never Job's. There's no virtual dispatch.
A common use of embedding: adapter types. The standard library's http.HandlerFunc is the classic:
// A function type with a method - so a plain function can satisfy http.Handler.
type HandlerFunc func(http.ResponseWriter, *http.Request)
func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f(w, r)
}
// Now any function with the right signature can be wrapped:
http.Handle("/hi", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "hi")
}))
This is a recurring Go pattern: define a function type with a method, and you've turned plain functions into things that satisfy interfaces.
Try it: 1. Build the Job/Logger example. Confirm j.Log("starting") works through promotion.
-
Add a method
func (j *Job) Log(msg string)onJobitself that prepends the command name. Notice that nowj.Log(...)calls Job's Log (which can still callj.Logger.Log(...)if it wants). -
Define
type StringSet func(string) bool(a function type). Give it aContains(s string) boolmethod that just callsf(s). Now aStringSetis both a function (callable) and an object satisfying aContainerinterface (you'd define) requiringContains. This dual nature is the trick.
Inside an interface value: iface and the itable¶
Russ Cox wrote the canonical reference for this, and the picture is simple. Every interface value in Go is a two-word struct:
interface value (iface):
┌──────────────┬──────────────┐
│ itab pointer │ data pointer │
└──────┬───────┴──────────────┘
│
▼
itab:
{ interface type info
concrete type info
[func pointer for method 1]
[func pointer for method 2]
... }
itab pointer- points to an interface table. The itab is keyed by the (interface type, concrete type) pair. It holds type metadata and a list of function pointers - one per method the interface requires, pointing at the concrete type's implementation.data pointer- points to the actual value (or the value itself if it's word-sized and the runtime can pack it in).
When you call s.Speak() on an interface value s, the compiler generates code roughly equivalent to:
That's the whole dispatch - one indirect call. No reflection, no method-name lookup, no inheritance walk. The itab does the work once, when the interface value is assigned, then caches.
For the empty interface (any / interface{}), there are no methods to dispatch, so the itab degenerates to just a pointer to the type. The runtime calls this variant eface (for "empty face"). It's used by fmt.Println, type assertions on any, the reflect package - everywhere a value needs to flow with its type but no specific behavior.
Sizes worth knowing:
- An interface value is two words (16 bytes on 64-bit machines), even if the underlying value is one byte.
- Passing an interface is cheap (two-word copy).
- Going from concrete type → interface costs an itab lookup the first time the (interface, concrete-type) pair is seen; cached after that.
- Going from interface → concrete (type assertion) is a runtime check: compare the itab's type info with the target type.
Try it:
import (
"fmt"
"unsafe"
)
type Greeter interface{ Greet() string }
type Person struct{ Name string }
func (p Person) Greet() string { return "Hi, " + p.Name }
func main() {
var g Greeter = Person{Name: "Ada"}
fmt.Println(unsafe.Sizeof(g)) // 16 - two words
fmt.Println(g.Greet()) // Hi, Ada
fmt.Printf("%T\n", g) // main.Person - itab told us the type
}
The %T print is reaching into the itab to recover the concrete type.
The nil interface gotcha (typed nil)¶
The most famous Go pitfall. Read this carefully - most beginners hit it within their first six months.
An interface value is nil if and only if both its itab pointer and its data pointer are nil. The implication:
type MyError struct{ Code int }
func (e *MyError) Error() string { return "boom" }
func mightFail() error {
var e *MyError // e is a typed nil: type=*MyError, value=nil
return e // returned as a non-nil interface!
}
func main() {
err := mightFail()
if err != nil { // TRUE - even though e was nil
fmt.Println("got an error:", err)
}
}
Trace it: e is a nil pointer of type *MyError. When returned as an error, Go boxes it into an interface value with itab pointer → *MyError's itabanddata pointer → nil. The itab is *not* nil; the interface is *not* nil. Theerr != nil` check passes even though there was no actual error to report.
This is the typed-nil gotcha. The fix is to return the untyped nil literal directly when there's no error:
func mightFail() error {
var e *MyError
if everythingFine {
return nil // untyped nil - interface really is nil
}
e = &MyError{Code: 42}
return e
}
Or even more disciplined: avoid declaring an *MyError variable and instead initialize it inline at the return:
func mightFail() error {
if everythingFine {
return nil
}
return &MyError{Code: 42} // no chance of accidentally returning typed nil
}
The bug bites most often when functions return concrete error types and a developer thinks "I'll return the zero value when there's no error." Don't. Return the literal nil.
Try it: 1. Build the buggy version. Confirm the err != nil check fires.
-
Replace with the fixed version. Confirm now
err == nil. -
Use
errors.Is(err, nil)- does it reporttruefor the buggy case? (No - it walks the wrap chain, and a typed-nil error wrapping nothing isn't "equal to nil" by that walk either. This is why nil-checking errors viaerrors.Is(err, nil)doesn't save you. The fix is at the return site.)
"Accept interfaces, return structs" (Postel's Law for Go)¶
A Go proverb most strongly associated with Dave Cheney and the broader Go community:
Accept interfaces, return structs.
It's Postel's Law applied to function signatures - "be conservative in what you do, liberal in what you accept." Expanded:
- Functions should accept the smallest interface that captures what they need. Don't take
*os.Filewhen you only need to write bytes - takeio.Writer. This lets callers pass anything writable: a file, a buffer, a network connection, a test fixture. - Functions should return concrete types. Returning an interface forces callers to use only the methods the interface exposes. Returning the concrete struct lets each caller pick the level of abstraction they want - they can immediately assign to an interface if they prefer.
Example:
// Good: accepts a small interface, returns a concrete type.
func WriteHeader(w io.Writer, name string) (*Header, error) {
_, err := fmt.Fprintf(w, "X-Source: %s\r\n", name)
if err != nil {
return nil, err
}
return &Header{Name: name}, nil
}
// Less good: accepts a concrete type (over-specified), returns an interface (under-specified).
func WriteHeader(w *os.File, name string) (io.ReadCloser, error) { ... }
The trick is: don't define interfaces preemptively in the package that supplies a type. Let interfaces emerge from the consumers. The io.Reader interface is defined in io, not in os (where *os.File lives) - because anyone who wants to consume readable bytes can declare their own io.Reader-shaped need.
You'll see exceptions. Interfaces that abstract a major capability (like error) live with the concept. Constructors that only need to return an abstract behavior (like crypto/cipher.NewCBCEncrypter returning cipher.BlockMode) return an interface. The proverb is a default, not an absolute.
Try it: Write a small function func summarize(name string, body io.Reader) (int, error) that returns the number of bytes in body. Call it with strings.NewReader("hello"), with bytes.NewBuffer([]byte("world")), with an open *os.File. All three work. The function never knew or cared which.
Compile-time satisfaction checks¶
When you've written a type and you want it to satisfy a specific interface, but the type doesn't get used as that interface in your package - there's an idiom to make the compiler check at compile time:
type MyHandler struct{ ... }
func (h *MyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ... }
// Compile-time check: *MyHandler must satisfy http.Handler.
// The variable is named _ so it's discarded.
var _ http.Handler = (*MyHandler)(nil)
The line says: "create a discarded variable of type http.Handler, initialized to a nil *MyHandler." Type-checking forces the compiler to verify *MyHandler satisfies http.Handler. If you later remove or rename ServeHTTP, the compiler complains immediately, instead of at the first downstream caller.
You'll see this idiom at the top of files that export a type intended for interface use. It's a contract-enforcement trick with zero runtime cost (the nil value never gets used).
Try it: In a small file, write a type that almost satisfies io.Writer (e.g., method signature is Write(p []byte) int - missing the error return). Add var _ io.Writer = (*YourType)(nil). The compiler error tells you exactly what's wrong. Without that line, the error would only surface when someone tried to use your type as a writer.
Generics extend interfaces (Go 1.18+)¶
Since Go 1.18, interfaces have an additional role: they can define type constraints for generic functions and types. The constraint syntax adds type elements alongside (or instead of) method signatures:
// A constraint: "any type whose underlying type is int, int32, int64, or float64".
type Number interface {
~int | ~int32 | ~int64 | ~float64
}
// A generic function constrained by Number.
func Sum[T Number](values []T) T {
var sum T
for _, v := range values {
sum += v
}
return sum
}
func main() {
fmt.Println(Sum([]int{1, 2, 3})) // 6
fmt.Println(Sum([]float64{1.5, 2.5})) // 4
}
The ~int means "any type whose underlying type is int" - so a user-defined type Celsius int would also be accepted. The | is a union; the constraint is "any of these types."
Important restriction: interfaces that include type elements (int | float64, ~int, etc.) can only be used as constraints. They can't be the type of a regular variable. The interface concept is split into two roles since 1.18: basic interfaces (methods only, usable as values) and general interfaces (also include type elements, usable only as constraints).
For a deeper treatment, see the Go blog's introduction to generics. The short version: interfaces grew a second job, but most everyday Go is still about basic interfaces and method satisfaction.
Where this material came from¶
- Effective Go - Interfaces - implicit satisfaction philosophy, the
-ernaming convention,Stringer, the empty interface. - Effective Go - Pointers vs values - method receiver rules with the standard library examples.
- Effective Go - Embedding - method promotion via anonymous fields, the
HandlerFuncadapter pattern. - Go Data Structures: Interfaces - Russ Cox (research.swtch.com) - the canonical iface/itab/eface explanation with diagrams.
- Go language spec - Method sets - the precise method-set rules.
- Go language spec - Interface types - type elements, unions, the basic/general interface distinction.
- An Introduction to Generics - Go blog - the Go 1.18 generics design.
- Typed nils in Go 2 - Dave Cheney - the most famous Go pitfall.
- SOLID Go Design - Dave Cheney - "accept interfaces, return structs" in context.
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.
Now extend it with an interface:
-
Define a
Circlestruct with onefloat64fieldRadius. Add methodsArea() float64(returns π·r²; usemath.Pi) andPerimeter() float64(returns 2π·r). -
Define a
Shapeinterface that requires bothArea() float64andPerimeter() float64. -
Write a function
Describe(s Shape)that prints"area=X, perimeter=Y". It should accept any type satisfyingShape- bothRectangleandCirclework. -
Call
Describe(Rectangle{Width: 5, Height: 3}). Then callDescribe(Circle{Radius: 4}). The same function handles both. -
Stretch: add a
String() stringmethod toRectanglethat returns"Rect(WxH)". Nowfmt.Println(r)uses it automatically - becauseRectanglesatisfiesfmt.Stringerimplicitly. -
Stretch: add a compile-time check at the top of the file:
Try removing theArea()method fromCircle. The compiler tells you exactly where the contract broke.
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 interfaces (which you met above) handle the cases other languages use inheritance for - and they do it in a more flexible way, because satisfaction is implicit instead of declared.
"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 { ... } (and on non-struct types too). - 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. - Choose between value and pointer receivers (modification → pointer). - Define an interface that captures a behavior - and pass any type with the right methods through it. - Use the empty interface (any) when you genuinely accept anything. - Recover a concrete type from an interface value with type assertions or type switches.
You can now model things that have multiple properties and write code that works against contracts rather than against specific types. Combined with what came before, you can build programs that are both data-rich and flexible enough to test, swap implementations, and grow without rewriting.
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.
What a slice actually is (the mental model)¶
Here's what surprises people: a slice in Go is not a list. It's a tiny three-field struct that describes a list living elsewhere in memory.
The three fields:
slice header:
┌─────────┬─────┬─────┐
│ pointer │ len │ cap │
└────┬────┴─────┴─────┘
│
▼
backing array: [ A ][ B ][ C ][ D ][ E ]
- pointer - where the elements actually live in memory.
- length - how many elements the slice currently uses (what
len(s)returns). - capacity - how big the underlying array is from the pointer onward (what
cap(s)returns).
When you write s := []int{1, 2, 3}, Go allocates an array of 3 ints somewhere, then makes a slice header pointing at it: {ptr → [1 2 3], len=3, cap=3}.
You can see all three with the built-in cap function:
The big consequence: when you pass a slice to a function, you copy the header - three small fields. You do not copy the elements. The function and the caller end up with two slice headers pointing at the same underlying array. Writes through one header are visible through the other. We'll come back to this in Going deeper.
Try it: 1. Type the program above and add s = append(s, 4). Print len(s) and cap(s) again. What changed?
- Try
t := make([]int, 3, 10). What dolen(t)andcap(t)show? What doestactually contain before you write to it?
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").
The growing-array story. When you append, Go does one of two things:
- There's room (
cap > len): write into the existing backing array, return a slice header withlenbumped by one. Fast. - There's no room (
cap == len): allocate a new, bigger backing array, copy the old elements in, write the new element, return a slice header pointing at the new array. The old array is left to the garbage collector.
That's why we always reassign: names = append(names, ...). The header you get back may point at a different array than the one you passed in.
Watch it happen:
s := []int{}
for i := 0; i < 10; i++ {
s = append(s, i)
fmt.Printf("len=%2d cap=%2d\n", len(s), cap(s))
}
You'll see capacity jump in chunks (e.g., 1 → 2 → 4 → 8 → 16), not grow by one each time. Go roughly doubles capacity when it reallocates, so a million appends don't take a million allocations - they take about twenty. We'll see the exact growth strategy in Going deeper.
Try it: 1. Run the loop above. Note the capacities you see.
-
Change
10to100. Watch the jumps. Does the doubling slow down at any point? -
Add
fmt.Println("pointer:", &s[0])just afters = append(...). When the pointer changes, you know a reallocation just happened.
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.
Strings are almost slices¶
A Go string is structurally like a slice header without the capacity: a pointer to bytes plus a length. The pointer-to-bytes points at immutable memory - you can't change a character in place. Everything else works like you'd expect.
Two surprises live here.
len(s) is bytes, not characters. A Go string is a sequence of bytes (which may be UTF-8 text). For ASCII text (English, code, numbers), bytes and characters line up - len("hello") is 5. For non-ASCII characters, one character can take multiple bytes:
s[0] gives you a byte, not a character. That's why fmt.Println(s[0]) printed 104 - the byte value of 'h'. To work with characters (Go calls them runes - Unicode code points stored as int32), use range:
Output:
range over a string decodes UTF-8 for you. Each iteration gives the byte offset of the rune (so the index can jump by 1, 2, or 3 depending on the rune's width), plus the rune itself as an int32. This is the only place in Go where the language treats UTF-8 specially.
You can slice a string just like a slice - s[1:3] - but you're slicing bytes, not characters. "café"[3:] returns the last two bytes (which together encode é); "café"[3:4] returns the first half of é and is not valid UTF-8. String slicing only makes sense at known rune boundaries.
The fast way to count actual characters:
import "unicode/utf8"
s := "café"
fmt.Println(len(s)) // 5 (bytes)
fmt.Println(utf8.RuneCountInString(s)) // 4 (runes/characters)
Try it: 1. Run the range example with s := "日本語" (Japanese for "Japanese language"). Each rune is 3 bytes - predict the byte offsets before you run it.
-
Print
len("日本語")and the count from afor rangeloop. They'll differ. -
Try
fmt.Println("café"[3:4]). You'll see a single byte that's not valid on its own. Now tryfmt.Println("café"[3:5])- that's the fullé.
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:
What a map actually is (the mental model)¶
A map in Go is a pointer to a hash table.
That short sentence packs three useful facts:
-
It's a pointer. When you pass a map to a function, you're passing a small pointer-sized value. The function operates on the same underlying table. Modifications stick - there's no "pass a copy" the way you'd get with a struct.
-
It's a hash table. Lookups, inserts, and deletes are amortized O(1) - the cost doesn't grow with how much is in the map. Go hashes the key, finds the right slot, and does the operation.
-
The variable can be
nil. Maps must be initialized before you write to them, or your program panics.
var m map[string]int // nil map
fmt.Println(m["anything"]) // 0 -- reading a nil map is OK
m["new"] = 1 // PANIC: assignment to entry in nil map
Always create a map with make or a literal:
m := make(map[string]int) // empty, ready to use
m := map[string]int{"a": 1, "b": 2} // empty + initial entries
Because a map is a pointer, sharing one between functions is cheap and side effects are visible:
func addOne(scores map[string]int, k string) {
scores[k] = scores[k] + 1
}
func main() {
scores := map[string]int{"alice": 0}
addOne(scores, "alice")
addOne(scores, "alice")
fmt.Println(scores["alice"]) // 2
}
No need to return the map - the function and caller see the same hash table.
Try it: 1. Type the addOne example. Confirm it prints 2. Now copy the map first: func addOne(scores map[string]int, ...) { scoresCopy := scores; ... }. Does the change still stick? Why? (Hint: scoresCopy is another pointer to the same table.)
-
Try
var m map[string]int; m["x"] = 1. See the panic. Addm = make(map[string]int)before the write to fix it. -
Run any program that uses a map with two goroutines writing to it (you'll meet goroutines on page 09). Use
go run -race main.go. Notice the race-detector complaint - maps are not safe for concurrent writes.
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 production Go. The material below is for when you want to know why things behave the way they do - depth that lets you predict surprises instead of being surprised by them. Most of this is drawn directly from the Go team's own writing; links at the end.
How append's growth strategy actually works¶
You saw above that append roughly doubles capacity when it has to reallocate. That's true for small slices. For large ones, Go grows more conservatively to avoid wasting memory:
-
For slices below roughly 256 elements: capacity is doubled (×2) on each reallocation.
-
For slices past that threshold: each reallocation grows by progressively less, settling around ×1.25.
The exact thresholds change between Go versions and are not part of the language spec - they're a runtime implementation choice. The principle is constant: small slices grow fast (so the amortized cost of building one stays cheap) and large slices grow slow (so you don't waste megabytes when you only needed kilobytes more).
You don't memorize the numbers. The takeaway: appending one element at a time to a million-element slice is amortized O(1), but each individual append is sometimes O(N) when reallocation happens. If you know the final size up front, pre-size with make([]T, 0, N) - you'll get zero reallocations.
Try it:
s := []int{}
prev := 0
for i := 0; i < 10_000; i++ {
s = append(s, i)
if cap(s) != prev {
fmt.Printf("grew at len=%d to cap=%d (ratio %.2f)\n",
len(s), cap(s), float64(cap(s))/float64(prev|1))
prev = cap(s)
}
}
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 inherits a's spare capacity)
b = append(b, 99) // append wrote into a's memory because there was room
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 constantly. Recognize it.
Try it: 1. Run the second example. Confirm a is mutated.
- Change
b := a[:3]tob := a[:3:3](the three-index slice - explicitly limits b's capacity to 3). Now what happens when youappend? Why? (Answer: withcap == len, append has to reallocate; the new array is independent.)
The retained-array gotcha¶
Slicing keeps the entire backing array alive, even if you only kept a tiny window. This bites in real production code:
import (
"bytes"
"io"
)
// Bad: keeps the entire file in memory forever
func firstLine(r io.Reader) []byte {
all, _ := io.ReadAll(r) // could be megabytes
i := bytes.IndexByte(all, '\n')
if i < 0 {
return all
}
return all[:i] // looks like 100 bytes, pins the whole array
}
The returned slice is a 100-byte view, but its pointer still aims at the 10 MB array. The garbage collector cannot reclaim the array while any slice references it. If you keep millions of these "small" return values, you actually hold millions of full file buffers.
The fix: copy the bytes you actually want, so the original array can be freed:
func firstLine(r io.Reader) []byte {
all, _ := io.ReadAll(r)
i := bytes.IndexByte(all, '\n')
if i < 0 {
return all
}
out := make([]byte, i)
copy(out, all[:i])
return out // independent buffer; `all` can be GC'd
}
This pattern is straight from the Go blog. The standard library uses it everywhere a small slice is returned from a large buffer.
Try it: Write a program that reads a 1 MB file into b := os.ReadFile(...), takes head := b[:10], and stores 10,000 of these head values in a slice. Check runtime.MemStats after. Switch to the copy version and compare. The difference is dramatic.
Strings: conversion costs (sometimes)¶
Because strings are immutable and []byte is mutable, converting between them requires a copy:
b := []byte("hello") // allocates 5 bytes, copies "hello" in
s := string(b) // allocates 5 bytes, copies them back
b[0] = 'H' // changes b. s still says "hello".
For small strings this is invisible. For large ones in a hot loop, it can dominate your CPU profile.
The compiler optimizes one specific case: using string(b) as a map key.
In all other cases, treat string(b) and []byte(s) as real allocations.
Try it: Benchmark with testing.B:
func BenchmarkStringConv(b *testing.B) {
data := []byte("the quick brown fox jumps over the lazy dog")
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = string(data)
}
}
go test -bench=. -benchmem. You'll see one allocation per op for the conversion. Now change the inner line to _ = data. The allocs drop to zero. Maps in Go 1.24: Swiss tables¶
In Go 1.24 (released February 2025), the map implementation was rewritten to use a design called Swiss tables, borrowed from Google's C++ Abseil library.
The mental model shifted:
- Before Go 1.24: a hash table of "buckets" each holding 8 key/value slots, with overflow buckets chained when a bucket fills up.
- Go 1.24 and later: a hash table of "groups" of 8 slots, each group accompanied by a 64-bit "control word" that lets one CPU instruction check all 8 slots for a key match. When a group is full, the lookup uses quadratic probing to find the next group rather than walking an overflow chain.
The result, per the Go team's microbenchmarks: map operations up to 60% faster, memory usage down up to 70% on dense workloads. Datadog publicly reported saving hundreds of gigabytes of RAM after upgrading.
From your code's perspective, nothing changed - same make, same m[k], same delete, same iteration semantics. But when you read older blog posts that describe "Go map buckets" and "overflow chains," know that's the pre-1.24 model.
Try it: 1. Run go version. If it's go1.24 or later, your maps are Swiss tables.
- Build a small benchmark that fills a
map[int]intwith a million entries, then measures lookup time. If you have access to Go 1.23 and Go 1.24, run on each and compare. You should see Go 1.24 measurably faster.
Map iteration: random order, plus what's defined about modification¶
You already know iteration order is randomized. There are two more rules worth knowing.
Modifying a map during iteration is defined behavior, with caveats spelled out in the Go spec:
- Deleted entries are not produced.
- Updated values are produced (if the entry hasn't been visited yet, you'll see the new value).
- New entries added during iteration may or may not be produced.
m := map[string]int{"a": 1, "b": 2, "c": 3}
for k := range m {
if k == "a" {
delete(m, "b") // b might not be visited (it isn't yet)
m["d"] = 4 // d may or may not be visited
}
}
Most hash-table implementations forbid this entirely - Python and Java throw runtime exceptions; the C++ Swiss table refuses it. Go's runtime keeps the iterator anchored to the original table even as the table grows, then re-checks the (possibly new) table before yielding each entry. That's why the pattern is legal.
It's still a code smell. If you find yourself modifying a map during iteration, collect the changes into a slice first, then apply them after the loop. Easier to reason about and immune to the "may or may not" rule.
Try it: Run the example above ten times. Does "d" show up? Sometimes - that's the "may or may not" from the spec, working as designed.
Pre-sizing: when you know the size¶
If you know roughly how many items you'll have, say so up front:
// Bad: ~9 reallocations as the slice grows from 0 to 1000
s := []int{}
for i := 0; i < 1000; i++ {
s = append(s, i)
}
// Good: 1 allocation, no growth
s := make([]int, 0, 1000)
for i := 0; i < 1000; i++ {
s = append(s, i)
}
Same for maps:
In hot paths this is a measurable performance win (fewer reallocs, less garbage). In cold paths it's a discipline that telegraphs intent to readers.
Try it: Benchmark both versions:
func BenchmarkAppend(b *testing.B) {
for i := 0; i < b.N; i++ {
s := []int{}
for j := 0; j < 1000; j++ { s = append(s, j) }
}
}
func BenchmarkAppendPresized(b *testing.B) {
for i := 0; i < b.N; i++ {
s := make([]int, 0, 1000)
for j := 0; j < 1000; j++ { s = append(s, j) }
}
}
go test -bench=. -benchmem. Compare allocs/op: typically ~9 for the first, exactly 1 for the second. nil slices behave (mostly) like empty slices¶
var s []int // nil slice: ptr=nil, len=0, cap=0
fmt.Println(len(s)) // 0
s = append(s, 1) // works! append handles nil - allocates a fresh array
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 pointing at a (possibly shared) zero-length array. For most purposes they're interchangeable - len, range, and append all work the same on both.
The one place it bites: JSON encoding. nil slices marshal to null; empty slices marshal to [].
import "encoding/json"
var a []int // nil
b := []int{} // empty
j1, _ := json.Marshal(a) // []byte("null")
j2, _ := json.Marshal(b) // []byte("[]")
If your API consumer expects [] (most JavaScript code does), return []int{}, not nil. The same applies to nil maps and map[K]V{}.
Try it: Write a quick HTTP handler that returns json.NewEncoder(w).Encode(struct{ Items []int }{items}). Call it twice - once with items = nil, once with items = []int{}. curl both. Notice the wire difference: {"Items":null} vs {"Items":[]}. Front-end code that does data.Items.map(...) will crash on the first and succeed on the second.
Where this material came from¶
The depth in this section is drawn from these authoritative sources. Bookmark them for when you want to go further:
- Go Slices: usage and internals - slice header, aliasing, retained-array gotcha.
- Strings, bytes, runes and characters in Go - by Rob Pike. The canonical string-internals reference.
- Go maps in action - comma-ok pattern, iteration order, concurrency rules.
- Faster Go maps with Swiss Tables - the Go 1.24 redesign explained by the Go team.
- How Go 1.24's Swiss Tables saved us hundreds of gigabytes - Datadog's production case study.
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? (the mental model)¶
Here's the part of Go that surprises people coming from other languages: error has almost no magic in it. The entire error type is defined like this in the standard library:
That's it. An error is any type that has a method called Error() returning a string. No special inheritance, no exception class to extend, no throws annotation, nothing. If your type has an Error() string method, your type IS an error and the compiler will accept it where an error is expected.
This is what people mean when they say "errors are values." Errors aren't a special control-flow mechanism - they're just regular values implementing a regular interface. You can store them in slices, send them through channels, compare them, pass them to functions, return them - anything you can do with any other value.
You met interfaces formally on page 05. The short version: an interface is a contract. The error contract is "I can tell you a string about what went wrong."
Here's the entire mechanism, demonstrated in one program:
package main
import "fmt"
// MyError is a custom error type - just a struct with an Error() method.
type MyError struct {
Code int
Msg string
}
func (e *MyError) Error() string {
return fmt.Sprintf("[%d] %s", e.Code, e.Msg)
}
func doStuff() error {
return &MyError{Code: 404, Msg: "not found"}
}
func main() {
err := doStuff()
fmt.Println(err) // [404] not found
fmt.Println(err.Error()) // [404] not found -- same thing
fmt.Println(err == nil) // false
}
fmt.Println(err) automatically calls err.Error() to get a string to print. There's no magic - just the interface contract being satisfied.
This means errors can carry any structured data you want. Want an error that knows an HTTP status code? Add a Status int field. Want one that knows which field failed validation? Add a Field string. The error is a regular struct; you can put anything in it. The caller can inspect it (you'll see how, in Going deeper).
Try it: 1. Type the program above. Confirm both prints show [404] not found.
-
Add a second method:
func (e *MyError) IsRetryable() bool { return e.Code >= 500 }. Insidemain, doif me, ok := err.(*MyError); ok && me.IsRetryable() { ... }. (You're doing a type assertion - checking iferris actually a*MyErrorunderneath.) -
Try returning
errors.New("simple")instead of&MyError{...}. What changes? (Hint:errors.Newreturns an unexported type with just anError()method.)
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 wrap chain (the mental model)¶
Each %w wrap creates a new error that points at the inner error - like a linked list:
your error ──Unwrap()──▶ intermediate error ──Unwrap()──▶ original error ──Unwrap()──▶ nil
"validating config" "parsing line 12" "unexpected '}' at col 5"
This is how the chain actually works in the runtime. A wrapped error is a struct with two fields: a message and an inner error. Its Unwrap() method returns the inner error. Call Unwrap() repeatedly and you walk back through the whole history until you hit nil.
You almost never call Unwrap() directly. Two helpers in the errors package walk the chain for you:
import "errors"
// Is the original error (or anything in the chain) a specific sentinel?
if errors.Is(err, io.EOF) { ... }
// Is the original error (or anything in the chain) a specific type?
var pathErr *os.PathError
if errors.As(err, &pathErr) {
fmt.Println("bad path:", pathErr.Path)
}
errors.Is walks the chain and returns true if any link in it equals the target. errors.As walks the chain and returns true if any link in it is the requested type (and fills in the pointer with it).
Run the wrap-chain demo yourself:
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
func main() {
inner := fmt.Errorf("lookup user 42: %w", ErrNotFound)
outer := fmt.Errorf("load profile: %w", inner)
fmt.Println(outer)
// load profile: lookup user 42: not found
fmt.Println(errors.Unwrap(outer))
// lookup user 42: not found
fmt.Println(errors.Unwrap(errors.Unwrap(outer)))
// not found
fmt.Println(errors.Is(outer, ErrNotFound))
// true -- errors.Is walked the entire chain
}
That last line is the whole point. Even though outer is two levels of wrapping above ErrNotFound, errors.Is(outer, ErrNotFound) returns true because it walked the chain. You compare against the root cause; you don't have to know how deep it's buried.
Try it: 1. Type the program. Confirm the output.
-
Replace one
%wwith%vand re-run. What doeserrors.Is(outer, ErrNotFound)return now? Why? (Hint:%vdoesn't wrap; it just flattens to a string. The chain breaks at that point.) -
Define a second sentinel
var ErrTemporary = errors.New("temporary"). Wrap both into a single error witherrors.Join(ErrNotFound, ErrTemporary). Checkerrors.Isagainst each - both return true. (You'll seeerrors.Joinagain in Going deeper.)
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, and why no exceptions?¶
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.
Why doesn't Go just use exceptions? Worth a moment because it explains the if-err-not-nil discipline.
Exception-based languages (Python, Java, JavaScript) treat failures as a separate control-flow channel: a function that raises an exception transfers control to whatever try/catch is nearest up the stack - invisibly, from the calling code's point of view. The advantages are concise happy-path code and centralized error handling. The disadvantages are that:
- Every function call is potentially an unexpected exit point. You can't tell by reading the code what might throw.
- The control flow is hidden. A function 10 frames down can raise an exception that resurfaces in a handler you wrote without knowing about that function.
- Resource cleanup is fragile without language support like
try-with-resourcesorusingblocks.
Go's authors decided that the tradeoff was wrong: the verbosity of if err != nil is worth paying for control flow that's visible at the call site. Every place a function might fail is right there in the code. There are no surprise exits.
You don't have to agree with the choice. You do have to live with it when you write Go.
(Panic exists for the cases exceptions handle in other languages - truly unrecoverable program bugs. The difference is that Go strongly discourages routinely using it. Errors are the default mechanism; panic is the escape hatch.)
Try it: 1. Trigger a panic deliberately: arr := []int{1, 2, 3}; fmt.Println(arr[10]). Run it. Read the stack trace.
- Compare with what happens when you forget to handle an error: replace your
if err != nilblock with just_ = err. The program may keep running on bad data, producing wrong results silently. Which failure mode would you rather debug - the loud crash with a stack trace, or the silent corruption?
Going deeper¶
Production Go error handling uses a few more tools than the basics. Material below is drawn from the Go team's own blog posts (links at the end).
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 universal: define a sentinel, wrap it when adding context, compare with errors.Is.
Try it: Build a tiny store package with var ErrNotFound = errors.New("not found"). Have Lookup return fmt.Errorf("lookup %q: %w", id, ErrNotFound). In main, call Lookup and use errors.Is. Confirm it returns true even though the error message is "lookup \"x\": not found", not just "not found".
Custom error types and errors.As¶
When you need more than a yes/no - say, "what HTTP status should this map to?" or "which field failed?" - 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)
fmt.Println("message: ", ve.Message)
}
errors.As is the typed cousin of errors.Is. It walks the wrap chain looking for an error whose dynamic type matches the target, and fills in the pointer when it finds one.
Try it: Define a RetryableError type with a RetryAfter time.Duration field and an Error() method. In a caller, use errors.As to extract it and print the retry delay. The point: errors aren't just messages - they can carry structured data the caller can use.
A custom Is method for template-style matching¶
Beyond the default "equal to this value" check, you can define your own Is(target error) bool method on a custom error type to implement custom matching. The Go 1.13 blog gives this example: match any error whose Path and User non-zero fields agree with the target.
type Error struct {
Path string
User string
}
func (e *Error) Error() string {
return fmt.Sprintf("error path=%s user=%s", e.Path, e.User)
}
// "Template match": non-zero target fields must match.
func (e *Error) Is(target error) bool {
t, ok := target.(*Error)
if !ok {
return false
}
return (t.Path == "" || e.Path == t.Path) &&
(t.User == "" || e.User == t.User)
}
Then callers can ask:
err := &Error{Path: "/etc/shadow", User: "alice"}
errors.Is(err, &Error{User: "alice"}) // true -- User matches; Path ignored
errors.Is(err, &Error{Path: "/etc/shadow"}) // true -- Path matches; User ignored
errors.Is(err, &Error{User: "bob"}) // false
This is the Go team's idiomatic pattern for "match a family of errors by partial criteria." Most code won't need it; when you do, it's the right shape.
Try it: Add a Reason string field to the Error struct above. Extend the Is method to also treat empty Reason in the target as a wildcard. Verify that errors.Is(err, &Error{Reason: "denied"}) matches only when reason is "denied".
Wrapping is an API decision, not a reflex¶
This is one of the most important lines from the Go 1.13 blog and worth re-reading slowly:
Wrapping an error makes that error part of your API. Committing to it in the future is required.
Once you write return fmt.Errorf("...: %w", sql.ErrNoRows), callers can write errors.Is(err, sql.ErrNoRows). You can never change the underlying storage mechanism (SQL → key-value store → cache → whatever) without breaking those callers. They depend on you returning that specific sentinel forever.
Three guidelines:
- Wrap to expose - when the inner error is genuinely useful to callers, and you're willing to keep it in your API contract.
- Don't wrap to hide - when the inner error is an implementation detail. Use
%v(which formats the error into the string but does not set up anUnwrap()link): - Don't wrap pointlessly - if you're not adding context, just
return err.
Try it: Write a function getUserAge(id) (int, error) that internally calls db.Query. Try two versions: one with %w and one with %v. In the caller, try errors.Is(err, sql.ErrNoRows). The first matches; the second doesn't. The first locks you in; the second leaves you free.
Wrap the sentinel even when there's no extra context¶
A subtle pattern from the Go team:
Why wrap a sentinel with no added context? Because it forces callers to use errors.Is instead of ==:
// Caller-A using == (works only because ErrPermission was returned directly):
if err == mypkg.ErrPermission { ... }
// Caller-B using errors.Is (works regardless of wrapping):
if errors.Is(err, mypkg.ErrPermission) { ... }
If you start by returning the sentinel directly and later decide to add wrapping (e.g., fmt.Errorf("user %q: %w", u.Name, ErrPermission)), every caller using == silently breaks. By wrapping from the start, you future-proof the API.
In other words: even an "empty" %w wrap is a deliberate API contract that says "compare me with errors.Is, not ==."
Try it: Write a tiny package with ErrPermission returned both ways. Write two callers - one with ==, one with errors.Is. Now modify the package to wrap the sentinel with extra context. Run both callers. The == one breaks; the errors.Is one keeps working. Lesson learned at no cost.
Behavior interfaces - extend errors with extra methods¶
Sometimes you don't want callers to know the concrete type of an error - you just want them to ask a question about it. The pattern: define an interface that extends error with extra methods.
The most famous example is net.Error from the standard library:
package net
type Error interface {
error
Timeout() bool // Is the failure a timeout?
Temporary() bool // Is the failure temporary (retry might succeed)?
}
Any network error in the standard library that satisfies this contract can be queried by callers:
if nerr, ok := err.(net.Error); ok && nerr.Temporary() {
time.Sleep(time.Second)
continue // retry
}
The caller doesn't care whether the error came from *net.OpError, *net.DNSError, or anywhere else - it just asks "are you temporary?" The error answers via its method.
You can build your own behavior interfaces the same way:
type retryable interface {
Retryable() bool
}
// In your package:
type myErr struct{ msg string; retry bool }
func (e *myErr) Error() string { return e.msg }
func (e *myErr) Retryable() bool { return e.retry }
// In any caller:
var r retryable
if errors.As(err, &r) && r.Retryable() {
// retry
}
The advantage over a typed errors.As check: callers don't need to import your error type. The interface is the contract.
Try it: Build a httpError type with a Status() int method. Have it satisfy a statuser interface defined in a separate package. Make the caller use errors.As(err, &s) (where s is of interface type) to ask for the status without knowing the concrete error type.
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...) // nil if errs is empty
}
errors.Join (Go 1.20+) bundles many errors into one. Two useful behaviors:
errors.Isanderrors.Asboth check every joined error, in order. If any one matches, the call returns true.- The joined value's
Error()method returns the inner errors' messages concatenated with newlines.
err := errors.Join(ErrTimeout, ErrNotFound)
errors.Is(err, ErrTimeout) // true
errors.Is(err, ErrNotFound) // true
fmt.Println(err)
// timeout
// not found
Try it: Write validateUser(u User) error that checks five fields, returning errors.Join of all the individual errors. In the caller, use errors.Is to ask if any specific validation failed. Compare the developer experience to returning the first error found.
When panic is actually correct¶
The "never panic" advice has a handful of exceptions:
- Program invariant broken. Your function takes a non-nil arg and got nil. The caller violated the contract. Panic; let them fix their bug. (Don't wrap this in a returned error - bugs aren't errors.)
- 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.- Truly unrecoverable runtime state. "The disk filled while writing the WAL and I have no way to roll back." This is the same case as init failure - there's nothing useful to do but stop.
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'll probably never write recover yourself.
Try it: Read the source of regexp.MustCompile (pkg.go.dev/regexp#MustCompile). It's three lines. Now grep your favorite Go open-source project for Must. Notice how rare it is - and how the cases where it appears all share the same shape: "compile-time constant input, no recovery needed."
Where this material came from¶
- Working with Errors in Go 1.13 - the canonical reference for
%w,errors.Is,errors.As, customIsmethods, and the wrapping-is-an-API-decision principle. - Error handling and Go - the older, philosophical piece. The "errors are values" framing, custom error types, the
net.Error-style behavior-interface pattern. errorspackage docs - forerrors.Joinand the chain-walking helpers.
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."
Memory diagrams (the mental model)¶
Every variable in your program lives at some address in memory. Picture memory as a long row of numbered boxes, where each box can hold one value. A variable's name is a label on one of those boxes.
x := 42 y := &x
address contents address contents
───────────────── ─────────────────
0x1000 │ 42 │ ◀── x 0x1000 │ 42 │ ◀── x
0x1008 │ ... │ 0x1008 │ ... │
0x1100 │0x1000│ ◀── y (holds the address of x)
Two simple rules cover all of pointer behavior:
&x- give me the address of the box labeledx.*p- go to the address stored inpand use the contents of that box.
When the diagram changes:
*y = 100 // "go to the address in y, write 100 into that box"
// address contents
// ─────────────────
// 0x1000 │ 100 │ ◀── x is now 100, because y pointed at it
// 0x1100 │0x1000│ ◀── y is unchanged (still holds the same address)
And the dangerous case - a pointer with no box to point at:
var p *int // p's box contains the special value nil
*p = 5 // "go to nil and write..." -- crash. Runtime panic.
This picture is what's actually happening at the CPU level: variables are addresses; pointers store addresses; *p is the CPU instruction "load from the address in p." Everything else in this chapter is just polish on this base.
Try it: 1. Type and run:
x := 42
y := &x
z := &y // z is a pointer to a pointer (a **int)
fmt.Println(x) // 42
fmt.Println(*y) // 42
fmt.Println(**z) // 42 - follow z to y, follow y to x
-
Now do
**z = 99and printx. What happened? -
fmt.Printf("%p\n", y)prints the actual address. Run it. Run the program twice - does the address change? (It can. Modern OSes randomize where programs land in memory for security; Go's runtime can also move stack data around as goroutine stacks grow.)
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." Two regions of memory with different properties.
- The stack is fast memory used for function calls. Each goroutine has its own stack. When a function is called, a new "frame" is pushed onto the stack holding its local variables. When the function returns, the frame is popped - the memory is reclaimed instantly, no garbage collector involved. Stacks are cheap and bounded.
- The heap is general-purpose memory that outlives any single function call. Things on the heap stick around until nothing points to them anymore - then Go's garbage collector (GC) reclaims them, eventually. Heap allocations are slower than stack allocations because they have to talk to the allocator and the GC.
Go decides for each variable where it lives. The compiler does escape analysis at compile time. The rule, in one sentence:
A value stays on the stack only if the compiler can prove it never outlives the function that created it.
Everything else follows from that:
- A local
intyou use and discard within the function → stack. - A local struct you take the address of and return to the caller → the caller might keep using it after your function returns, so the compiler moves it to the heap.
- A local value passed to
fmt.Println(which takes aninterface{}) → the compiler often can't prove whatPrintlndoes with it, so it errs safe and moves the value to the heap.
The phrase "escapes to the heap" is jargon for "the compiler couldn't prove this value's lifetime is bounded by the function, so it allocated on the heap instead."
Two consequences for everyday code:
-
You almost never have to think about this. The compiler handles it; the GC handles cleanup. Write clear code first.
-
Taking
&xof a local variable is not automatically a heap allocation. Sometimes the compiler can still prove the pointer doesn't outlive the function, and the value stays on the stack. The&operator is a request, not a command.
You'll see how to ask the compiler what it decided in Going deeper.
Reference types: maps, slices, channels¶
A specific case worth calling out, because it confuses people.
When you do s := otherSlice or m := otherMap, you're not copying the data. You're copying a small header that contains a pointer to the underlying data. Both variables now share the same data.
m1 := map[string]int{"a": 1}
m2 := m1 // m2 shares m1's hash table
m2["b"] = 2
fmt.Println(m1) // map[a:1 b:2] -- m1 sees b, because m1 and m2
// point at the same table
This is the same story you saw with slices on page 06. Maps, slices, channels (next page), and function values are reference types in Go: under the hood, they're really pointers to data structures. Copying a reference type copies the pointer-sized header, not the data.
Compare to value types (structs, arrays, primitive types like int):
type Box struct{ Count int }
b1 := Box{Count: 5}
b2 := b1
b2.Count = 99
fmt.Println(b1.Count) // 5 -- b1 is independent
b2 := b1 copied the struct's fields - they're entirely separate values now.
The trick: when you see func foo(m map[string]int), that function can modify your map even though it isn't *map[string]int. The map header it received is a copy of the header, but the header still points at your table. Writes through it modify your table. The same applies to slices and channels.
This is why "slices and maps are passed by reference" is the way people talk about it, even though technically Go always passes by value - the value in question is itself a pointer.
Try it: 1. Type the m1/m2 example. Confirm both see the change.
-
Try the
Boxexample. Confirm they're independent. -
Now write a function
func clear(m map[string]int)that doesfor k := range m { delete(m, k) }. Call it on a map. The function received "a copy" - but the map is empty after the call. Why? (Same answer: it got a copy of the header; both headers point at the same table.)
Going deeper¶
Production Go performance work eventually requires understanding the runtime: escape analysis, the garbage collector, stack growth, allocation pools. The material below is drawn from the Go team's own GC guide and runtime source. Links at the end.
Watch the compiler decide: -gcflags="-m"¶
Reminder of the one rule of escape analysis:
A value stays on the stack only if the compiler can prove it never outlives the function that created it.
You can ask the compiler to tell you, for any program, what escaped 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 the compiler couldn't prove its lifetime is bounded by the function.
Common reasons something escapes:
-
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 returns.
-
You store it in an
interface{}(or any interface). The compiler often can't see what the receiving code will do with it.fmt.Println(x)is the classic case -Printlntakes...any, so most concrete values passed in escape. -
You store it in a longer-lived data structure - a global slice, a map outside the function, a struct field that itself escapes.
-
You capture it in a closure that itself escapes the function.
For more detail, use -gcflags="-m=2" (level 2 is more verbose) or -gcflags="-m=3".
You won't tune this for normal code. You will eventually use it to chase a benchmark hot spot.
Try it:
package main
import "fmt"
func newPair(a, b int) *[2]int {
arr := [2]int{a, b} // does this stay on the stack?
return &arr // ...spoiler: no, because we return its address
}
func main() {
p := newPair(1, 2)
fmt.Println(p)
}
go build -gcflags="-m" main.go. You should see something like moved to heap: arr. Now refactor newPair to return [2]int (value, not pointer). Rebuild with -m. Notice the difference. The garbage collector: tricolor concurrent mark-and-sweep¶
Go's GC is a tricolor mark-and-sweep algorithm, and it's mostly concurrent - it runs while your program runs, with only brief stop-the-world (STW) pauses.
What "mark-and-sweep" means:
- Mark phase. Starting from "roots" (local variables on goroutine stacks, globals), the GC follows every pointer it finds, marking each reachable object as "live."
- Sweep phase. Memory that didn't get marked is dead - nothing points at it anymore. The GC makes that memory available for new allocations.
What "tricolor" means: during marking, every object is one of three colors.
- White: not yet known to be live (may be dead).
- Grey: known to be live, but its pointer fields haven't been scanned yet.
- Black: known to be live, AND its pointer fields have been scanned.
The GC starts with all objects white. It colors the roots grey. It picks a grey object, marks it black, and colors every white object it points to grey. Repeat until no grey objects remain. Anything still white at the end is dead.
This algorithm works concurrently with the running program - but with one catch: if your goroutine writes a pointer into an already-black object, the GC might miss it. To prevent this, Go uses write barriers: while the GC is in the mark phase, every pointer write goes through a tiny piece of compiler-inserted code that re-marks the involved objects as needed. This adds a small overhead during GC mark phases - typically a few percent of CPU.
The GC cycle, simplified:
[sweep previous garbage] → [off] → [mark live objects] → [sweep new garbage] → [off] → ...
▲▲ STW ▲▲ STW
pause to start pause to transition
mark phase mark → sweep
The two STW pauses are the only time your goroutines stop. Both are proportional to GOMAXPROCS, not to heap size. On a typical server, total pause time per cycle is well under a millisecond, even with multi-gigabyte heaps. That's Go's published guarantee.
The assist mechanism: if your program is allocating very fast and the background GC can't keep up, the GC asks for help. Whenever an allocating goroutine needs to allocate, it does a tiny bit of marking work first - proportional to how much it just allocated. This is "GC assist." It's the runtime's natural backpressure: allocate faster than the GC can mark, and your own goroutines have to help pay the bill.
The GC uses a target of 25% of CPU for mark work. So on a 4-core machine, one core is doing GC work during the mark phase. If your program is hammering allocation, GC assist temporarily uses more.
You'll see the assist if you profile a heavy-allocation program: runtime.gcAssistAlloc shows up in CPU profiles. More than ~5% of CPU in assist means your program is allocating faster than GC can keep up.
Try it:
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
for i := 0; i < 5; i++ {
// Allocate ~50MB of pointer-heavy garbage.
objs := make([]*[1024]int, 50_000)
for j := range objs {
objs[j] = &[1024]int{}
}
runtime.GC() // force a GC cycle
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("cycle %d: heap=%d MB, gc count=%d, total pause=%v\n",
i, m.HeapAlloc/1024/1024, m.NumGC, time.Duration(m.PauseTotalNs))
objs = nil
}
}
GOGC and GOMEMLIMIT: tuning the GC¶
Two environment variables (and runtime API equivalents) control GC behavior.
GOGC sets how aggressively the GC runs, as a percentage of live heap. The formula from the GC guide:
GOGC=100(the default): next GC kicks in when the heap doubles past the live size. ("100% headroom.")GOGC=50: GC runs more often; heap stays smaller (less memory, more CPU on GC).GOGC=200: GC runs less often; heap grows larger (more memory, less CPU on GC).GOGC=off: disable GC entirely. Don't do this in production.
GOMEMLIMIT (Go 1.19+) sets a soft upper bound on total memory the Go runtime uses. The GC tries to stay under this limit by running more often as the heap grows. It's "soft" because if your program is genuinely allocating faster than the GC can keep up, exceeding the limit briefly is preferred to letting the GC pin 100% of CPU - the limiter caps GC at ~50% CPU over 2 × GOMAXPROCS-second windows.
In production, the recommended setup from the Go guide:
- Set
GOGC=100(default; don't tune unless you have profiles showing why). - Set
GOMEMLIMITto ~95% of your container's memory limit. If your container has 1 GiB,GOMEMLIMIT=950MiB. The headroom is for the runtime, stacks, and non-Go memory. - Profile with
go tool pprofbefore tuning further.
Try it: Run the allocation program above with GOGC=50 (then GOGC=200). Notice how many more (or fewer) GCs happen, and what the heap peaks at.
sync.Pool for hot allocations - the implementation, not just the API¶
Suppose your HTTP handler builds a 4 KB buffer for every request. Each allocation pressures the GC. 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 ...
}
That looks simple, but what's actually happening underneath is worth knowing because it shapes how to use it well.
Per-P local pools. Each P (Go's logical processor - you'll meet these on page 09) has its own local pool. Get and Put on the current P don't touch any locks - they're as fast as a slice push/pop. This is what makes sync.Pool actually fast at scale; if it took a global lock, contention would destroy its purpose.
Victim cache and GC interaction. This is the surprising part. When a GC cycle starts, the runtime does:
- The contents of each P's local pool are moved to a per-P victim cache.
- The local pool is emptied.
If you call Get after this and the local pool is empty, the runtime first checks the victim cache; if found, the object survives one more cycle. Anything still in the victim cache at the next GC is discarded.
The consequence: objects you put in a pool live for at most 1–2 GC cycles before being discarded. A pool is not storage. It's a brief opportunity to reuse before GC reclaims.
Two surprises that follow:
- A
Getcan return a freshly-allocated object (via yourNewfunction) even if you justPutsomething - the GC may have cleared the pool between your calls. Don't assume anyGetreuses anything. Getreturns whatever was put in last; it may still contain old data. Always reset the returned value (buf[:0]for a[]byte, zero the struct fields, etc.) before using it.
Don't reach for sync.Pool preemptively. It pays off only when allocation profiles show GC pressure is real and the pooled object is genuinely hot.
Try it: Take any short-lived allocation in a hot loop. Run it twice - once with direct allocation, once with sync.Pool. Use go test -bench=. -benchmem and look at allocs/op. If pool reuse drops allocations near zero (and the benchmark accurately reflects production), it's a win. If allocations stay similar, the pool isn't helping - production GC may already be clearing it between Puts.
Struct field order matters (and group your pointer fields first)¶
Go aligns struct fields on natural boundaries. A bool is 1 byte, but the next int64 field needs 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 (end-of-struct alignment)
} // 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.
A second, related optimization from the GC guide: group pointer fields at the start of the struct. The GC walks pointer fields during marking. It stops scanning at the last pointer - anything after is non-pointer data the GC doesn't have to look at. Putting pointers up front lets the GC stop scanning earlier:
// Slightly faster to GC: pointers grouped at start.
type Node struct {
Next *Node // pointer
Prev *Node // pointer
Owner *User // pointer
Value int
Score float64
}
Neither of these is a code-style rule - readability comes first. But for structs allocated in huge quantities, the ordering matters.
Try it: Write a small program with []Sparse and []Dense of a million elements. Use runtime.ReadMemStats to compare HeapAlloc. The 8-byte difference per struct should show as 8 MB difference in heap size.
Stacks grow (corrected: 2 KB, not 8 KB)¶
Each goroutine starts with a 2 KB stack on modern Go. If your function calls go deeper than 2 KB worth of frames, the runtime detects this on entry to the next function, allocates a new bigger stack (typically double), copies the current stack into it, fixes up any internal pointers, and continues. You see nothing.
Compare to OS threads, which typically start with a fixed 1–2 MB stack. That's why "use lots of goroutines" actually works: a million goroutines might use ~2 GB of stack space; a million OS threads would use ~2 TB.
Stacks can grow up to 1 GB per goroutine by default before the runtime panics with "stack overflow." Deep recursion in Go doesn't blow up nearly as easily as in C or Java.
You'll never write code that touches this. But when someone says "goroutines are cheap because of growable stacks," now you know the actual numbers.
Try it: Write a deeply recursive function - say, mutual recursion that nests 100,000 deep. Run it. It works. Now try the same in any language with fixed-size threads. (Don't actually try this in production code; it's just for understanding.)
Reading a memory profile¶
When something feels slow, the next-level move is pprof. The fastest setup:
import _ "net/http/pprof"
func main() {
go func() { http.ListenAndServe("localhost:6060", nil) }()
// ... rest of your program ...
}
While the program runs:
$ go tool pprof http://localhost:6060/debug/pprof/heap
(pprof) top 10
(pprof) list YourFunctionName
(pprof) web
top shows which functions allocated the most memory. list shows allocation-by-line in a specific function. web opens an SVG graph in your browser.
For allocation profiling specifically:
This counts total allocations (good for finding wasteful code) rather than "currently allocated" memory (which is what heap shows).
You'll learn this in depth later. For now: know it exists, know how to start it, recognize the output when you see it.
Where this material came from¶
- A Guide to the Go Garbage Collector - the official, authoritative GC reference. Covers tricolor mark-and-sweep, write barriers, GOGC/GOMEMLIMIT formulae, struct field ordering tip.
- Getting to Go: The Journey of Go's Garbage Collector - Rick Hudson's ISMM 2018 keynote on the design history. Highly readable for a research talk.
sync.Poolsource - under 300 lines, including the per-P/victim-cache logic. Worth reading once.- Go memory model - relevant for the GC's interaction with concurrent goroutines, more relevant on page 09.
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.
What a goroutine actually is (the mental model)¶
A common misconception: "goroutines are threads, just lighter." Close, but not quite. The real model matters because it explains why goroutines are cheap and why Go can run a million of them.
Go uses a scheduler called the G-M-P model. Three letters, three roles:
- G - a goroutine. The unit of work. Created by every
go funcCall()statement. A G has a tiny stack (starting at 2 KB; grows on demand - you saw this on page 08) and some state about where it's running. - M - a machine, i.e. an OS thread. The thing that actually runs code on a CPU core.
- P - a processor (logical, not physical). A scheduling slot. You have one P per CPU core by default (controlled by
GOMAXPROCS).
The scheduler's job: multiplex many G's onto a small pool of M's, via the P's.
GOMAXPROCS=4 machine, mid-execution:
P0 P1 P2 P3
| | | |
M0 M1 M2 M3 (OS threads, doing the actual running)
| | | |
G(running) G(running) G(running) G(running)
local run queues (per-P):
P0: [G G G G]
P1: [G G]
P2: [] ← will steal from a busy P
P3: [G G G]
global run queue: [G G G G G G]
Each P has its own local queue of runnable goroutines. A G runs on an M (which is bound to a P). When the running G blocks (on a channel, on I/O, on a time.Sleep), the scheduler pops another G off the local queue and runs it instead - same M, same P, no OS context switch.
If a P's local queue empties, it tries the global queue. If that's also empty, it does work-stealing: it picks a random other P and grabs half of its queue. This keeps all CPU cores busy when there's any work to do.
That's why goroutines are cheap:
- 2 KB starting stack, growable, vs ~2 MB for an OS thread. A million goroutines use ~2 GB of stack; a million threads would use ~2 TB.
- No OS scheduler involvement to switch between goroutines on the same M. No kernel call, no context switch - just changing what the M is running.
go funcCall() doesn't start a thread. It creates a G, puts it on the current P's local queue, and immediately returns. The G runs whenever the scheduler picks it.
Try it:
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
fmt.Println("CPUs:", runtime.NumCPU())
fmt.Println("goroutines at start:", runtime.NumGoroutine())
for i := 0; i < 100; i++ {
go func() { time.Sleep(time.Hour) }()
}
time.Sleep(100 * time.Millisecond)
fmt.Println("goroutines after spawning 100:", runtime.NumGoroutine())
}
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.
What a channel actually is (the mental model)¶
A channel isn't magic. In the Go runtime, every make(chan T, N) allocates a small struct (the source calls it hchan) with these parts:
hchan {
buf: ring buffer of N elements (empty for unbuffered)
qcount: how many elements currently in buf
dataqsiz: N (capacity)
sendx: where the next send writes in buf
recvx: where the next receive reads from buf
closed: has close(ch) been called?
sendq: queue of goroutines blocked waiting to send
recvq: queue of goroutines blocked waiting to receive
lock: mutex protecting all of the above
}
Two distinct cases, both built from those pieces:
Unbuffered channel (make(chan int)): the buf is empty. The channel is purely a rendezvous point. When a goroutine sends, the runtime checks recvq for a waiting receiver: - Receiver waiting → hand the value directly to it, wake it up, sender continues. - No receiver → the sender's G is parked into sendq and the runtime puts the M to work on another G. Later, when a receiver arrives, it pulls the sender off sendq, takes the value, and wakes the sender.
Either way, the send and receive synchronize: both goroutines are guaranteed to have reached this point in their code at this moment.
Buffered channel (make(chan int, 5)): the buf is a 5-slot ring buffer. Sending writes into buf[sendx] if there's room (no waiting); if the buffer is full, the sender parks in sendq like the unbuffered case. Receiving reads from buf[recvx] if there's anything there; if the buffer is empty, the receiver parks in recvq.
unbuffered channel (rendezvous):
Sender ───┐ ┌─── Receiver
│ │
└──▶ [ no buf ] ◀──┘
▲
│
wait queues:
sendq: [G1 (parked, holding 42)]
recvq: []
Receiver arrives → pulls G1 off sendq, takes 42, wakes G1.
buffered channel (capacity 3):
buf: [10] [20] [30]
▲ ▲
recvx sendx (full → new senders park)
Receiver pulls 10, recvx advances. Sender can now write at the freed slot.
That's the whole channel mechanism. There's no magic queue - it's a struct, a ring buffer, two wait queues, and a mutex. Once you have this picture, close(), range ch, and select all stop feeling mysterious. (close() sets the closed flag and wakes everyone in sendq and recvq; receives from a closed channel return immediately with the zero value and ok = false.)
Try it: 1. Run a quick experiment: ch := make(chan int, 2); ch <- 1; ch <- 2; ch <- 3. The third send blocks forever (the buffer is full and there's no receiver). The whole program deadlocks. Add <-ch before the third send and it works. 2. Try ch := make(chan int); close(ch); v, ok := <-ch; fmt.Println(v, ok). Prints 0 false. Receives from a closed empty channel return zero values and ok = false immediately. 3. Read the actual hchan source (links at the bottom of Going deeper). It's only a few hundred lines and explains everything you just saw.
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.
Happens-before: why synchronization isn't just about waiting¶
This is the deepest concurrency idea on this page. It's short, and it explains why channels and mutexes are non-negotiable.
Without coordination, writes from one goroutine may never become visible to another. Modern CPUs and compilers reorder reads and writes for performance, and each CPU core has its own cache. A value one goroutine wrote at time T might not appear in another goroutine's view until some time later - or not at all, if the compiler decides the original write can be optimized away entirely.
So this code, which looks like it should work, can fail silently:
var data string
var ready bool
go func() {
data = "hello, world"
ready = true
}()
for !ready {
// wait...
}
fmt.Println(data) // might print "" instead of "hello, world"!
The reading goroutine might observe ready = true before it observes the write to data. There's no rule of physics that says writes complete in source-code order across CPU cores. They don't, by default.
The fix is to introduce a synchronization point - an operation that the language guarantees establishes an ordering between two goroutines. Go's memory model defines exactly which operations do this. The two you've already seen are at the top of the list:
- A channel send happens-before the matching receive completes. Everything the sender did before the send is guaranteed visible to the receiver after the receive.
- An unlock happens-before any subsequent lock of the same mutex. Everything the goroutine did before unlocking is guaranteed visible to the next goroutine that locks.
So the fixed version:
ready := make(chan struct{})
var data string
go func() {
data = "hello, world"
close(ready) // synchronization point
}()
<-ready // happens-after the close
fmt.Println(data) // guaranteed "hello, world"
The receive of the closed channel is synchronized with the close, which is synchronized with the write to data. Now data is guaranteed visible.
From the Go memory model spec:
If you must read the rest of this document to understand the behavior of your program, you are being too clever. Don't be clever.
Translation: always reach for channels, mutexes, or sync primitives to coordinate goroutines. Never trust intuition about memory visibility. The cost of "being clever" is a bug that appears only under heavy load, only on certain machines, with no reproducible trigger.
Try it: Run the broken version above. On most machines it will appear to work - the writes happen to land in order most of the time. Add GOMAXPROCS=8 go run -race main.go. The race detector flags the data race. The fact that it didn't crash is a lie; the bug is still there. The -race flag is the only honest test for this.
Going deeper¶
Production concurrent Go is mostly the basics above plus several patterns and a deeper understanding of the runtime that makes goroutines and channels work. The material below is drawn from the Go memory model spec, the runtime source, and authoritative scheduler write-ups (links at the end).
The four happens-before rules you actually need¶
The Go memory model is precise. There are several rules, but four cover almost all real code:
- Within a single goroutine, reads and writes happen in the order they appear in the source code. (Subject to compiler reordering that's invisible from inside the goroutine. This is the rule that lets the compiler optimize at all.)
- Channel send happens-before the matching receive completes. For unbuffered channels, the receive happens-before the send completes - this is stronger (synchronous rendezvous). For buffered channels with capacity C, the k-th receive happens-before the (k+C)-th send.
- Mutex
Unlockhappens-before any subsequentLockon the same mutex. The n-thUnlockhappens-before the (n+1)-thLockreturns. Same forRWMutex.Unlock→ nextLock, andRUnlock→ nextLock. - A
gostatement happens-before the started goroutine begins executing. Everything in the caller beforego f()is visible insidef.
These four cover ~95% of correctness reasoning in real code. The other rules in the spec are edge cases (specifically: sync.Once, atomics, the relationship between buffered receives and earlier sends).
The practical implication: every shared variable must be guarded by one of these synchronization edges. If you can't draw a happens-before arrow from "the write" to "the read," your code has a data race even if it currently appears to work.
Try it: Take this snippet and try to convince yourself it's wrong:
The writex = 42 is in one goroutine; the read in fmt.Println(x) is in another (main). There's no channel, no mutex, no sync operation between them - no happens-before edge. Run with -race. The race detector reports it. The fix is to add a channel: done := make(chan struct{}); go func() { x = 42; close(done) }(); <-done; fmt.Println(x). Three broken patterns the spec explicitly warns about¶
The memory model spec calls out specific patterns that look right and are wrong. Recognize them.
1. Busy-waiting on an unsynchronized flag.
var ready bool
var data string
go func() {
data = "computed"
ready = true // no synchronization
}()
for !ready { // also no synchronization
runtime.Gosched()
}
fmt.Println(data) // may print ""
Without a channel or mutex, the writes to data and ready can be reordered or invisible to the other goroutine. The fix: replace ready with close(readyCh) and replace the loop with <-readyCh.
2. Double-checked locking (the C++/Java pattern transplanted incorrectly).
var instance *Service
var mu sync.Mutex
func get() *Service {
if instance == nil { // unsynchronized read
mu.Lock()
defer mu.Unlock()
if instance == nil {
instance = newService()
}
}
return instance
}
The first if instance == nil is unsynchronized. A racing goroutine might see instance as non-nil but observe an only-partially-constructed Service. Use sync.Once instead:
var instance *Service
var once sync.Once
func get() *Service {
once.Do(func() { instance = newService() })
return instance
}
sync.Once.Do is documented to establish a happens-before edge between the action and every subsequent Do call. This is what you want.
3. Sending on a channel from a select to make default block.
The default runs whenever the send case isn't immediately ready. That's not the same as "channel is full." If no receiver is currently waiting on an unbuffered channel, default runs even when no one is "wrong." This pattern is mostly fine in practice but the reasoning is subtle - if you find yourself relying on it for correctness rather than performance, reconsider.
Try it: Build the busy-waiting example. Run it 100 times in a row in a loop (for i := 0; i < 100; i++ { ... }). On most machines it'll print "computed" most of the time, "" rarely. The bug is real but probabilistic. -race makes it deterministic.
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.
How the scheduler actually picks goroutines¶
You met G-M-P in the main flow. Going one level deeper:
Each P has a local run queue - a small fixed-size ring buffer (256 slots, in current Go). New goroutines created on this P go to the back of the queue. When the running G blocks or finishes, the P picks the next G from the front.
There's also a global run queue. When a P's local queue is full, half its goroutines are moved to the global queue. When a P's local queue is empty, it checks the global queue before stealing.
Work-stealing. When a P finds nothing in its local queue and nothing in the global queue, it picks a random other P and steals half of that P's local queue. This is the magic that keeps all cores busy: no P stays idle while another has work.
Preemption. A G doesn't run forever. Since Go 1.14, the runtime can interrupt any G after about 10 ms via asynchronous preemption (delivered via OS signal). Before 1.14, preemption only happened at function call boundaries - tight loops without function calls could starve other goroutines. Modern Go doesn't have this problem.
Blocking calls. When a G makes a blocking syscall (file read, network call), the runtime can: - Detach the M from its P, leave the M waiting on the kernel. - Create or take a new M for the P, so other goroutines on that P keep running.
When the syscall returns, the original M tries to get a P back; if none is available, the G is put on a queue for some P to pick up later.
The result: blocking syscalls don't block other goroutines scheduled to the same P. Network I/O in particular is handled even more cleverly - the runtime uses non-blocking syscalls with epoll/kqueue so the M doesn't even park.
Try it: Run a server that handles thousands of concurrent HTTP requests. Watch runtime.NumGoroutine() over time. You'll see thousands of goroutines, all blocked in net.Read, with only a handful of M's actually running. That's the scheduler at work.
Channels in the runtime - the actual source¶
The hchan struct you met in the main flow is in src/runtime/chan.go. The whole file is about 800 lines and surprisingly readable. The key functions:
makechan- allocates the hchan struct plus the buffer (if N > 0).chansend- implementsch <- v. Walksrecvqfirst (handoff to waiting receiver); falls back to the buffer; otherwise parks the sending G insendq.chanrecv- symmetric: walkssendqfirst, then buffer, then parks inrecvq.closechan- setsclosed, wakes everyone in both queues.
The clever bits:
sudog- short for "suspended goroutine." A small struct linked intosendq/recvqto remember which G is waiting and what value it wants to send/receive. The samesudogmechanism is used bysync.Mutex,sync.WaitGroup, etc.- Direct handoff for unbuffered channels. When sender meets receiver, the value is written directly from the sender's stack to the receiver's stack - never through the buffer, since unbuffered channels have no buffer. This saves a copy and is the kind of optimization that makes channels fast.
selectis compiled into a single call toselectgo, which inspects all the cases atomically. There's no "polling" inselect.
Read chan.go once when you want to fully understand channels. You don't need to memorize it - but seeing the structure removes all remaining mystery.
Where this material came from¶
- The Go Memory Model - the authoritative source for happens-before rules and broken-pattern examples.
- Faster Go channels with
hchanandsudog- the runtime source. Surprisingly readable. - Go's work-stealing scheduler - Jaana Dogan's clear write-up of the G-M-P model.
- The Go scheduler - Daniel Morsing's blog post; one of the canonical references for how G-M-P works.
- Go: How Are Deadlocks Triggered with
select- the spec's definition of select semantics.
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.