Saltar a contenido

Go From Scratch (Beginner)

Beginner path: from never-coded to reading and contributing to real OSS Go.

Printing this page

Use your browser's PrintSave 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:

  1. 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.
  2. Errors are blunt. Go's compiler tells you exactly what's wrong and which line. You'll spend less time confused.
  3. 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.
  4. 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:

  1. Says what you'll learn this session.
  2. Shows you a small program.
  3. Walks through the code line by line.
  4. 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 .pkg file. Double-click; follow the prompts.
  • Windows: download the .msi file. Double-click; follow the prompts.
  • Linux: either download the .tar.gz from go.dev/dl and follow the instructions there, or use your distro's package manager: sudo apt install golang-go on Debian/Ubuntu, sudo dnf install golang on 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:

go version

You should see something like:

go version go1.22.0 darwin/arm64

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:

mkdir -p ~/code/go-learning
cd ~/code/go-learning

Then check you're there:

pwd

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:

package main

import "fmt"

func main() {
    fmt.Println("hello, world")
}

Save the file.

Step 4: Run it

Back in your terminal, in the same folder as hello.go, type:

go run hello.go

You should see:

hello, world

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. main is 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 the fmt package (short for "format"). fmt has functions for printing and formatting text. To use code that lives somewhere else, you have to import it first.

  • func main() - Defines a function called main. A "function" is a named block of code. main is special: it's the function that runs when the program starts. Every Go program needs exactly one main function in the main package.

  • fmt.Println("hello, world") - Calls the Println function (the "Pl" stands for "print line") from the fmt package, with "hello, world" as the input. Println prints 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 to main.

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:

  1. Change "hello, world" to your name. Run again. Did it work?

  2. Add a second line that prints something else:

    fmt.Println("hello, world")
    fmt.Println("this is my second line")
    

  3. Try printing a number - no quotes:

    fmt.Println(42)
    

  4. 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.)

  5. Put the } back. Now mistype Println as println (lowercase p). Run. Read the error. Go is case-sensitive: Println and println are 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.

Next: First real program →

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:

go run greet.go

You should see:

Alice is 30 years old

What's new here

Two lines you haven't seen before:

name := "Alice"
age := 30

Let's unpack name := "Alice":

  • name is 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:

fmt.Println(name, "is", age, "years old")

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:

var name string = "Alice"
var age int = 30

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 / y gave 3, not 3.333.... That's because x and y are ints - 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 % y gave 1. The % operator gives the remainder after division. 10 / 3 is 3 with 1 left over, so 10 % 3 is 1. We'll use this often to test "is this number even?" (n % 2 == 0 is true if n is 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:

n := 5
s := "items: "
fmt.Println(s + n)   // ERROR

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:

n := 5
s := fmt.Sprintf("items: %d", n)
fmt.Println(s)   // items: 5

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:

isReady := true
hasPermission := false
fmt.Println(isReady, hasPermission)   // true false

Exercise

Type this in a new file called me.go:

Write a program that:

  1. Has a variable for your name (a string).
  2. Has a variable for your favorite number (an int).
  3. Has a variable for whether it's morning right now (a bool).
  4. 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.

Next: Decisions and loops →

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 if age >= 18 is 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:

age := 25
hasLicense := true
if age >= 18 && hasLicense {
    fmt.Println("can drive")
}

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:

for i := 1; i <= 5; i++ {
    fmt.Println(i)
}

That for line is doing three things, separated by semicolons:

  1. i := 1 - create a variable i starting at 1. (This happens once, before anything else.)
  2. i <= 5 - the condition that keeps the loop going. Checked before each round.
  3. i++ - what to do after each round. (i++ is shorthand for i = i + 1.)

The flow is:

  1. Set i = 1. Check 1 <= 5. True → run the body. Print 1. Then do i++i = 2.
  2. Check 2 <= 5. True → print 2. Then i = 3.
  3. ... continues ...
  4. Check 5 <= 5. True → print 5. Then i = 6.
  5. Check 6 <= 5. False → stop.

Output:

1
2
3
4
5

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:

n := 10
for n > 0 {
    fmt.Println(n)
    n = n - 1
}

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:

for {
    // runs until you break out
}

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 Fizz instead of the number.
  • If the number is divisible by 5, print Buzz instead.
  • 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:

1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
16
17
Fizz
19
Buzz

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:

for {
    // body that should run at least once
    if !condition {
        break
    }
}

"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.

Next: 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:

  1. You can't see what the program does by looking at it. The structure is gone.
  2. You can't reuse anything. If you compute "the square of a number" in three places, you've typed x * x three times.
  3. 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

func name(parameters) returnType {
    // body
    return something
}

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 called double. It takes one input (called a parameter), named x, of type int. It returns an int. The body is between the curly braces.
  • return x * 2 - computes x * 2 and sends that value back to whoever called the function.
  • double(5) - calls the function with 5 as the value of x. The function runs, returns 10, and Println prints 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:

func add(a int, b int) int {
    return a + b
}

Or, when several parameters share a type, you can collapse the declaration:

func add(a, b int) int {
    return a + b
}

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:

func sayHi(name string) {
    fmt.Println("Hi,", name)
}

func main() {
    sayHi("Alice")
    sayHi("Bob")
}

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, both ints."
  • 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:

func main() {
    a := 3
    b := 4
    result := (a * a) + (b * b)
    fmt.Println(result)
}

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:

  1. Write a function isEven(n int) bool that returns true if n is even and false otherwise.

Hint: use the % operator from Page 02. n % 2 == 0 is a bool.

  1. From main, print isEven(4) and isEven(7). You should see true and false.

  2. Write a second function countEvens(max int) int that counts how many even numbers are in 1, 2, 3, ..., max. It should use a for loop and call your isEven function for each.

  3. From main, print countEvens(10). You should see 5 (the evens 2, 4, 6, 8, 10).

  4. 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 types05-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:

{Alice 30 Lagos}
Alice
30

What's new:

  • type Person struct { ... } - defines a new type called Person. 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 type Person, 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:

func (p Point) DistanceFromOrigin() float64 {

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:

func distanceFromOrigin(p Point) float64 { ... }

That's not wrong. But methods read better at the call site:

p.DistanceFromOrigin()        // method
distanceFromOrigin(p)         // regular function

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:

func (p *Point) MoveBy(dx, dy float64) {
    p.X += dx
    p.Y += dy
}

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 *Type even 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.

  1. Try Counter{Value: 5}.IncByPointer() directly. Compile error: "cannot take the address of Counter{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.

  2. Change IncByValue to also return c (so it returns the copy after incrementing). Call c = c.IncByValue(). Now the change sticks - because you're explicitly assigning the returned copy back. This is how append works, 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:

const (
    a = iota   // a = 0
    b = iota   // b = 1
    c = iota   // c = 2
)

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 << 10 is 2^10 which is 1024.
  • 1 << 20 is 2^20 which is 1,048,576 (about 1 million).
  • 1 << 30 is 2^30 which is 1,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 written KB = 1 << (10 * 0) here we'd get KB = 1, not KB = 1024 - that's the line we wanted to skip.)
  • Line KB ByteSize = 1 << (10 * iota) -> iota = 1, so KB = 1 << 10 = 1024.
  • Line MB -> iota = 2, so MB = 1 << 20 = 1,048,576.
  • Line GB -> iota = 3, so GB = 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:

  1. Run the ByteSize example. Try printing ByteSize(500), ByteSize(2_000_000_000). The output adapts to scale.

  2. Try var size ByteSize = 1.5; var f float64 = size. Compile error: cannot use size (type ByteSize) as type float64. You need an explicit conversion: f := float64(size). The distinct-type rule has teeth.

  3. Define type WordCount int with a method Plural() string that 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) - uses Barker like any other type. Inside the function, b is 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.

  1. Add a type Cat struct{ Name string } without a Bark() method. Try makeNoise(Cat{Name: "Whiskers"}). Compile error: "Cat does not implement Barker (missing Bark method)". The compiler checks at compile time, before your program runs.

  2. Add a func (c Cat) Bark() string { return c.Name + " meows" }. Now makeNoise(Cat{...}) works. The cat satisfies Barker purely because it has the right method - even though we never wrote anywhere that Cat "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:

type Stringer interface { String() string }
Any type with a 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.

  1. That's how fmt.Println(size) printed "4.77 MB" earlier. The fmt package internally does if s, ok := v.(Stringer); ok { return s.String() }. Your ByteSize.String() got called because ByteSize happens to satisfy Stringer.

  2. The error type you've been using is also an interface: type error interface { Error() string }. Same mechanism. Any type with Error() string is 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.

  1. Comment out the String() method. Re-run. Now fmt.Println(price) prints {1999 USD} - the default struct formatting. The fmt package looked for Stringer, didn't find it, fell back.

  2. Define type Username string with a String() method that returns "@" + string(u). Print one. Notice: a Username is a string underneath but Stringer controls how it prints.

The empty interface and any

There's a special interface that requires no methods:

type interface{}    // older spelling
type any            // Go 1.18+ alias; identical

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:

// fmt.Println's actual signature (simplified):
func Println(values ...any) (int, error)

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:

  1. Functions that take any lose 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.

  2. Prefer specific interfaces over any. func writeTo(w io.Writer) is much better than func writeTo(x any) - the first tells the reader (and the compiler) what's expected. Reach for any only when you genuinely accept anything.

Try it:

  1. Define func describe(v any) { fmt.Printf("type=%T value=%v\n", v, v) }. Call it with an int, a string, and your Money struct. The %T verb prints the dynamic type.

  2. Note that you can't do v + 1 inside describe - even if you pass an int. The compiler doesn't know it's an int once it's in a any slot. 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:

s, ok := v.(string)
if ok {
    fmt.Println("it's a string:", s)
} else {
    fmt.Println("not a string")
}

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 T is the set of methods declared with receiver T.
  • The method set of *T is the set of methods declared with receiver T or *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

  1. Method value with a pointer receiver: define func (p *Point) Magnitude() float64 { ... }. Bind m := pp.Magnitude where pp := &Point{X: 3, Y: 4}. Mutate pp.X = 99. Call m(). The result changes - because m captured 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:

  • *Logger inside Job with no field name is called an anonymous (or embedded) field.
  • Methods on the embedded type are promoted: callers can do j.Log(...) exactly as if Log were defined on Job.
  • This is composition, not inheritance. Job has-a Logger; it does not is-a Logger. 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.

  1. Add a method func (j *Job) Log(msg string) on Job itself that prepends the command name. Notice that now j.Log(...) calls Job's Log (which can still call j.Logger.Log(...) if it wants).

  2. Define type StringSet func(string) bool (a function type). Give it a Contains(s string) bool method that just calls f(s). Now a StringSet is both a function (callable) and an object satisfying a Container interface (you'd define) requiring Contains. 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:

s.itab->fun[0](s.data)        // method 0 = Speak; first arg = the underlying value

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.

  1. Replace with the fixed version. Confirm now err == nil.

  2. Use errors.Is(err, nil) - does it report true for 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 via errors.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.File when you only need to write bytes - take io.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

Exercise

In a new file shapes.go:

  1. Define a struct Rectangle with two float64 fields: Width and Height.

  2. Add two methods on Rectangle:

  3. Area() float64 - returns Width * Height.
  4. Perimeter() float64 - returns 2 * (Width + Height).

  5. In main, create a Rectangle with width 5 and height 3. Print its area and perimeter. (Expected: 15 and 16.)

  6. Write a regular function LargerOf(a, b Rectangle) Rectangle that returns whichever rectangle has the bigger area. Test it with two rectangles of different sizes.

  7. Add a constructor NewSquare(side float64) Rectangle that returns a Rectangle with both fields set to side. Test it: NewSquare(4).Area() should be 16.

Now extend it with an interface:

  1. Define a Circle struct with one float64 field Radius. Add methods Area() float64 (returns π·r²; use math.Pi) and Perimeter() float64 (returns 2π·r).

  2. Define a Shape interface that requires both Area() float64 and Perimeter() float64.

  3. Write a function Describe(s Shape) that prints "area=X, perimeter=Y". It should accept any type satisfying Shape - both Rectangle and Circle work.

  4. Call Describe(Rectangle{Width: 5, Height: 3}). Then call Describe(Circle{Radius: 4}). The same function handles both.

  5. Stretch: add a String() string method to Rectangle that returns "Rect(WxH)". Now fmt.Println(r) uses it automatically - because Rectangle satisfies fmt.Stringer implicitly.

  6. Stretch: add a compile-time check at the top of the file:

    var (
        _ Shape         = Rectangle{}
        _ Shape         = Circle{}
        _ fmt.Stringer  = Rectangle{}
    )
    
    Try removing the Area() method from Circle. 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 once06-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"; string says "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:

s := []int{1, 2, 3}
fmt.Println(len(s), cap(s))   // 3 3

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?

  1. Try t := make([]int, 3, 10). What do len(t) and cap(t) show? What does t actually 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:

  1. append returns a value; it doesn't change the slice in place. Forgetting the names = part is a common bug - your append does nothing visible.

  2. 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 with len bumped 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.

  1. Change 10 to 100. Watch the jumps. Does the doubling slow down at any point?

  2. Add fmt.Println("pointer:", &s[0]) just after s = 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:

for i, name := range names {
    fmt.Println(i, name)
}

Output:

0 Alice
1 Bob
2 Chioma

range names gives you two values per iteration: the index and the value at that index. If you don't need the index, use _:

for _, name := range names {
    fmt.Println(name)
}

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.

s := "hello"
fmt.Println(len(s))    // 5
fmt.Println(s[0])      // 104  -- the byte 'h', not the character!

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 := "café"
fmt.Println(len(s))    // 5  -- not 4!  ('é' is two bytes in UTF-8)

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:

s := "café"
for i, r := range s {
    fmt.Printf("byte %d: rune %c (%U)\n", i, r, r)
}

Output:

byte 0: rune c (U+0063)
byte 1: rune a (U+0061)
byte 2: rune f (U+0066)
byte 3: rune é (U+00E9)

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.

  1. Print len("日本語") and the count from a for range loop. They'll differ.

  2. Try fmt.Println("café"[3:4]). You'll see a single byte that's not valid on its own. Now try fmt.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 are strings and whose values are ints. Read it as "a map FROM string TO int."
  • ages["Alice"] - look up the value for the key "Alice".

Add or update an entry:

ages["Dimeji"] = 40   // adds a new entry
ages["Alice"] = 31    // updates Alice's age

Delete an entry:

delete(ages, "Bob")

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:

  1. 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.

  2. 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.

  3. 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.)

  1. Try var m map[string]int; m["x"] = 1. See the panic. Add m = make(map[string]int) before the write to fix it.

  2. 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?

score := ages["Zara"]
fmt.Println(score)   // 0

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

for key, value := range ages {
    fmt.Println(key, value)
}

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:

var temps [7]float64

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)
    }
}
Run it. Watch the ratio start near 2.0 and shrink past ~256 elements.

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:

b := append([]int(nil), a[:3]...)   // independent backing array

The Go standard library does this constantly. Recognize it.

Try it: 1. Run the second example. Confirm a is mutated.

  1. Change b := a[:3] to b := a[:3:3] (the three-index slice - explicitly limits b's capacity to 3). Now what happens when you append? Why? (Answer: with cap == 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.

b := []byte("key")
v := m[string(b)]       // recognized pattern, no allocation

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)
    }
}
Run 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.

  1. Build a small benchmark that fills a map[int]int with 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:

m := make(map[string]int, 1000)   // pre-size internal table for ~1000 entries

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) }
    }
}
Run 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:

Exercise

In a new file wordcount.go:

Write a program that counts how many times each word appears in a sentence.

  1. Start with this sentence: "the quick brown fox jumps over the lazy dog the end". Hardcode it as a string.

  2. Split it into words. Use:

    import "strings"
    words := strings.Fields(sentence)
    
    strings.Fields splits a string on whitespace and returns a []string.

  3. Build a map[string]int where each key is a word and each value is how many times it appeared.

  4. Print each word and its count, one per line.

Expected output (order may differ):

the 3
quick 1
brown 1
fox 1
jumps 1
over 1
lazy 1
dog 1
end 1

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: Errors07-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:

got the number: 42
could not parse: strconv.Atoi: parsing "hello": invalid syntax

What's happening:

  • strconv.Atoi("42") succeeds. err is nil (the special "nothing" value). We skip the if and use n.
  • strconv.Atoi("hello") fails. err is a non-nil error value with a useful message. We enter the if, print it, and return from 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 T is the real result and error is nil.
  • If something failed: the T is usually the zero value (don't trust it), and error is 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:

type error interface {
    Error() string
}

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.

  1. Add a second method: func (e *MyError) IsRetryable() bool { return e.Code >= 500 }. Inside main, do if me, ok := err.(*MyError); ok && me.IsRetryable() { ... }. (You're doing a type assertion - checking if err is actually a *MyError underneath.)

  2. Try returning errors.New("simple") instead of &MyError{...}. What changes? (Hint: errors.New returns an unexported type with just an Error() 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.

  1. Replace one %w with %v and re-run. What does errors.Is(outer, ErrNotFound) return now? Why? (Hint: %v doesn't wrap; it just flattens to a string. The chain breaks at that point.)

  2. Define a second sentinel var ErrTemporary = errors.New("temporary"). Wrap both into a single error with errors.Join(ErrNotFound, ErrTemporary). Check errors.Is against each - both return true. (You'll see errors.Join again 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:

  1. Every function call is potentially an unexpected exit point. You can't tell by reading the code what might throw.
  2. 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.
  3. Resource cleanup is fragile without language support like try-with-resources or using blocks.

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.

  1. Compare with what happens when you forget to handle an error: replace your if err != nil block 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:

  1. Wrap to expose - when the inner error is genuinely useful to callers, and you're willing to keep it in your API contract.
  2. 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 an Unwrap() link):
    return fmt.Errorf("lookup user: %v", err)   // flattened; callers can't errors.Is it
    
  3. 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:

// Looks pointless, but isn't:
if !userHasPermission(u) {
    return fmt.Errorf("%w", ErrPermission)
}

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.Is and errors.As both 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.
  • MustXxx constructors. 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, custom Is methods, 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.
  • errors package docs - for errors.Join and the chain-walking helpers.

Exercise

In a new file parse.go:

Write a function parsePositive(s string) (int, error) that:

  1. Parses s as an integer using strconv.Atoi.
  2. If Atoi fails, returns the error wrapped with %w and a message like "parsePositive %q: %w".
  3. If the parsed number is less than or equal to zero, returns an error like "parsePositive: number must be positive, got %d" (use fmt.Errorf, no wrap because there's no underlying error to wrap).
  4. 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 memory08-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 to x.
  • *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 := &x made p point at x. Now p knows where x lives.
  • *p = 100 followed the pointer back to x and wrote 100 into it. x is now 100.

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 labeled x.
  • *p - go to the address stored in p and 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

  1. Now do **z = 99 and print x. What happened?

  2. 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 a Counter, not a Counter.
  • increment(&counter) - we pass the address of counter, 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:

func (c *Counter) Increment() {
    c.Value = c.Value + 1
}

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:

func (c Counter) Increment() {
    c.Value = c.Value + 1   // mutates a copy; pointless
}

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 *Type even 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:

if p != nil {
    fmt.Println(*p)
}

When to use pointers

The honest answer:

  1. A method needs to mutate its receiver. Use a pointer receiver.

  2. You're passing a large struct around and don't want to copy it every time. Use a pointer.

  3. You explicitly want a "nullable" value - a thing that might or might not be set. Use a pointer; check nil.

  4. 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 int you 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 an interface{}) → the compiler often can't prove what Println does 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:

  1. You almost never have to think about this. The compiler handles it; the GC handles cleanup. Write clear code first.

  2. Taking &x of 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.

  1. Try the Box example. Confirm they're independent.

  2. Now write a function func clear(m map[string]int) that does for 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:

  1. You return a pointer to it. The caller might keep that pointer past your function's lifetime, so the value can't die when your function returns.

  2. 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 - Println takes ...any, so most concrete values passed in escape.

  3. You store it in a longer-lived data structure - a global slice, a map outside the function, a struct field that itself escapes.

  4. 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)
}
Build with 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:

  1. Mark phase. Starting from "roots" (local variables on goroutine stacks, globals), the GC follows every pointer it finds, marking each reachable object as "live."
  2. 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
    }
}
Run it. Notice the total pause time. On most machines it's a few hundred microseconds even for these multi-megabyte allocations. That's the concurrent GC at work.

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:

Target heap size = live_heap + (live_heap + GC_roots) × GOGC / 100
  • 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.

GOMEMLIMIT=2GiB go run main.go

In production, the recommended setup from the Go guide:

  • Set GOGC=100 (default; don't tune unless you have profiles showing why).
  • Set GOMEMLIMIT to ~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 pprof before 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:

  1. The contents of each P's local pool are moved to a per-P victim cache.
  2. 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:

  1. A Get can return a freshly-allocated object (via your New function) even if you just Put something - the GC may have cleared the pool between your calls. Don't assume any Get reuses anything.
  2. Get returns 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:

$ go tool pprof -alloc_space http://localhost:6060/debug/pprof/allocs

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

Exercise

In a new file account.go:

  1. Define a struct Account with two fields: Owner (string) and Balance (float64).

  2. Add a method Deposit(amount float64) that adds amount to Balance. Choose the right receiver type (value or pointer).

  3. Add a method Withdraw(amount float64) error that:

  4. Subtracts amount from Balance if Balance >= amount.
  5. Returns nil on success.
  6. Returns an error like "insufficient funds: have X, want Y" otherwise.

  7. In main:

    acct := Account{Owner: "Alice", Balance: 100}
    acct.Deposit(50)
    fmt.Println(acct.Balance)            // 150
    if err := acct.Withdraw(200); err != nil {
        fmt.Println("withdraw failed:", err)
    }
    acct.Withdraw(50)
    fmt.Println(acct.Balance)            // 100
    

  8. 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 10109-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") starts say running 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) in main is 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())
}
Run it. You'll see ~101 goroutines (your 100 + main). Bump the loop to 1,000,000 - still works fine. The program might use ~2 GB of RAM, but it runs. Try the same with OS threads in any language and watch your machine struggle.

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 a Done call to happen when this function returns. Done decrements the counter. (defer is 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 carries int values. make is a built-in for creating channels, slices, and maps.
  • ch <- 42 - send the value 42 into 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:

for value := range ch {
    fmt.Println(value)
}

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 int without make, 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:

  1. 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.)
  2. 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.
  3. Mutex Unlock happens-before any subsequent Lock on the same mutex. The n-th Unlock happens-before the (n+1)-th Lock returns. Same for RWMutex.Unlock → next Lock, and RUnlock → next Lock.
  4. A go statement happens-before the started goroutine begins executing. Everything in the caller before go f() is visible inside f.

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:

var x int

go func() { x = 42 }()
fmt.Println(x)   // race
The write x = 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.

select {
case ch <- v:
default:
    // assume "channel is full" - wrong reasoning
}

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:

$ go test -race ./...

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 - implements ch <- v. Walks recvq first (handoff to waiting receiver); falls back to the buffer; otherwise parks the sending G in sendq.
  • chanrecv - symmetric: walks sendq first, then buffer, then parks in recvq.
  • closechan - sets closed, wakes everyone in both queues.

The clever bits:

  • sudog - short for "suspended goroutine." A small struct linked into sendq/recvq to remember which G is waiting and what value it wants to send/receive. The same sudog mechanism is used by sync.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.
  • select is compiled into a single call to selectgo, which inspects all the cases atomically. There's no "polling" in select.

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

Exercise

In a new file parallel.go:

Write a program that:

  1. Has a slice of 10 numbers: nums := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}.
  2. For each number, in a separate goroutine, computes its square and sends the result on a channel.
  3. In main, receives all 10 squares from the channel and adds them up.
  4. 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: Tests10-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 parameter t *testing.T.
  • You run tests with go test.

Set up:

mkdir mathutils && cd mathutils
go mod init mathutils

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:

go test

You should see:

PASS
ok      mathutils    0.001s

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 with Test (capital T) followed by a capital letter or digit. The parameter must be *testing.T exactly.
  • 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 -v or 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.

  1. Make a folder ~/code/go-learning/wordtools. cd into it. Run go mod init wordtools.

  2. Create words.go:

    package wordtools
    
    import "strings"
    
    func WordCount(s string) int {
        return len(strings.Fields(s))
    }
    
    func IsPalindrome(s string) bool {
        s = strings.ToLower(s)
        for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
            if s[i] != s[j] {
                return false
            }
        }
        return true
    }
    

  3. Create words_test.go. Write table-driven tests for both:

  4. WordCount: "" → 0, "hello" → 1, "hello world" → 2, " many spaces here " → 3.
  5. IsPalindrome: "" → true, "a" → true, "racecar" → true, "hello" → false, "Racecar" → true (note: lowercased first).

  6. Run go test -v. All tests should pass.

  7. 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 modules11-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."

  • 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.mod file 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:

mkdir -p ~/code/go-learning/greetapp && cd ~/code/go-learning/greetapp
go mod init greetapp

That creates go.mod:

module greetapp

go 1.22

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:

go run .

Output: Hello, Alice!.

What's new:

  • The greet/greet.go file starts with package greet. That's the name code uses to refer to it.
  • main.go says import "greetapp/greet" - the full path is <module>/<folder>. The first part comes from go.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.

type Person struct {
    Name string   // visible outside the package
    age  int      // NOT visible - internal
}

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:

import (
    "fmt"
    "strings"

    "greetapp/greet"
    "github.com/some/external"
)

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

go 1.22

require github.com/fatih/color v1.17.0
  • module coloredhello - the import path of this module. If you publish to GitHub, this should be github.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.

type Stringer interface {
    String() string
}

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:

  1. Start a new module: mkdir ~/code/go-learning/bank && cd ~/code/go-learning/bank && go mod init bank.
  2. Create account/account.go with package account. Put your Account struct from page 08 (with Owner, Balance, Deposit, Withdraw) in it. Make the right things exported (capital first letter) and the right things unexported.
  3. Create main.go at the top level. Import bank/account. Create an account, deposit, withdraw, print results.
  4. Run go run ..
  5. Try referencing an unexported name from main.go. Read the error. Fix it.

Part 2 - a third-party library:

  1. In a new folder, start a module.
  2. Run go get github.com/fatih/color.
  3. Use one of the color functions (color.Green, color.Red, etc.) to print something.
  4. Run go mod tidy. See what changes in go.mod (probably nothing - you imported what you needed).
  5. Look at go.mod and go.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 code12-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:

  1. 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.

  2. List the top-level directories. Common Go layout:

  3. cmd/ - actual programs you can run (one folder per binary).
  4. internal/ - code that's private to this project.
  5. pkg/ - public reusable packages (less common now; many projects skip this folder).
  6. api/ - schema files (OpenAPI, protobuf, etc.).
  7. docs/ - documentation.
  8. scripts/, hack/, tools/ - helper scripts.
  9. vendor/ - copy of dependencies (rare since Go modules; some projects still use it).

  10. Open go.mod. What's the module path? What are the direct dependencies? This tells you which ecosystem the project lives in.

  11. Find the entry point. Usually main.go or cmd/<name>/main.go. Read main from 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."

  12. Read one test file. Pick a small _test.go and 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.

go doc fmt
go doc fmt.Println
go doc github.com/some/pkg

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:

func Contains(s, substr string) bool {
    return Index(s, substr) >= 0
}

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 ...: - like switch but 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:

  1. Read the README.
  2. List the top-level directories. What does the layout suggest?
  3. Open go.mod. What does it depend on? (Probably almost nothing, for these.)
  4. Find the entry point. Trace main (or the most-public function) for 5 minutes.
  5. 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 project13-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:

  1. You pick something you use (Kubernetes, say).
  2. You spend three hours setting up the dev environment.
  3. You find a "good first issue" that hasn't been touched in six months.
  4. You spend two weeks understanding enough of the codebase to make a change.
  5. You submit a PR.
  6. Nobody reviews it for three weeks. Then a maintainer asks for changes you don't understand.
  7. 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:

  1. 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.
  2. The maintainers are active. PRs get reviewed within a week, ideally a few days. Issues get responses.
  3. There are labeled "good first issue" or "help wanted" tickets. These are pre-screened to be approachable.
  4. There's a CONTRIBUTING.md. Tells you the project's conventions - coding style, tests they expect, the PR process.
  5. 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.
  6. 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 by cobra, 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/grafana or grafana/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.

  1. 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.

  2. Compare. Pick the one that scores best on responsiveness and has at least 3 unclaimed first issues.

  3. Read its CONTRIBUTING.md end to end. Note any unusual requirements (e.g., signed commits, specific commit message format, dev container).

  4. Clone it locally:

    git clone https://github.com/<owner>/<repo>
    cd <repo>
    

  5. Run its tests:

    go test ./...
    
    Confirm they pass. If they don't on a fresh clone, that's a red flag - you may want to pick a different project.

  6. Browse the open good first issue tickets. 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 repo14-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.mod and go.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. Run make help if 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:

git clone https://github.com/peterbourgon/ff ~/code/ff
cd ~/code/ff

Look at the top-level structure:

ls

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:

  1. README.md - read it. What does ff do? (A flag and configuration parser.)
  2. go.mod - what's the module path? (github.com/peterbourgon/ff/v3.) Any dependencies? (Almost none - that's a quality signal.)
  3. No cmd/ - meaning this is a library, not a runnable program.
  4. No internal/ - meaning everything here is part of the public API or extensible.
  5. ff.go, parse.go - the core. Open them.
  6. *_test.go - tests right next to the code. Standard Go layout.
  7. testdata/ - fixtures for tests. Open one to see what kind of data the tests use.
  8. ffyaml/, ffjson/, fftoml/ - subpackages adding YAML/JSON/TOML config support. Each is independently importable.
  9. .github/workflows/ - what's in the CI? Open the workflow YAML. It probably runs go 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-123 or 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 (adds Signed-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 run must 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.

  1. Clone it locally.
  2. Walk the layout, file by file, applying the categories above. Write down where each piece lives.
  3. Read the CONTRIBUTING.md end to end. Note any unusual requirements.
  4. Open one CI workflow YAML in .github/workflows/. Identify: what commands does CI run? On what platforms? Against what Go versions?
  5. Run those CI commands locally (go test ./..., golangci-lint run, whatever the workflow does). Confirm they pass on a fresh clone.
  6. 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 contribution15-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:

  1. Fork the project on GitHub (a personal copy).
  2. Clone your fork to your machine.
  3. Add upstream as a remote (so you can pull future updates).
  4. Branch off main.
  5. Change the code; add a test.
  6. Run tests locally; make sure CI's command passes.
  7. Push to your fork.
  8. 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:

git clone https://github.com/<you>/<project>
cd <project>

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:

git remote add upstream https://github.com/<owner>/<project>
git fetch upstream

Check it worked:

git remote -v

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:

git fetch upstream
git checkout main
git merge upstream/main
git push origin main

(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:

git checkout -b fix/issue-123-clarify-error-message

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:

git add <files>
git commit -m "fix: clarify error message in foo.go (#123)"

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):

git commit -s -m "fix: ..."

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:

git push origin fix/issue-123-clarify-error-message

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:

  1. "LGTM, merging." Best case. Done.
  2. "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.
  3. "Thanks, but we don't want this." Rare for good first issue work, 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.
  4. 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:

  1. Pick another issue in the same project. Familiarity compounds; your second PR will be much faster.
  2. 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).
  3. Branch out. Use what you learned to evaluate and contribute to a Tier 2 or Tier 3 project from page 13.
  4. Build something of your own. Use Go to scratch a personal itch. Publish it. Iterate based on real use.
  5. 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.