The go Keyword
The go keyword is how you tell Go to run a function concurrently instead of waiting for it to finish. Put go in front of any function or method call and Go starts it as a goroutine — a lightweight, independently scheduled unit of execution — while the calling code moves on immediately. Goroutines are the foundation of everything else in Go’s concurrency model, including channels and the sync package, so understanding exactly what go does (and doesn’t do) is essential before you write any concurrent Go code.
Overview: How Goroutines and the go Keyword Work
When you write go f(x), Go does two things: it evaluates the function and its arguments right away (in the calling goroutine), and then it schedules the call to run as a new, independent goroutine. The statement go f(x) itself does not block — control returns to the next line immediately, without waiting for f to do anything.
A goroutine is not an operating system thread. The Go runtime maintains its own scheduler that multiplexes many goroutines onto a much smaller number of OS threads (the M:N scheduling model — M goroutines mapped onto N OS threads). Each goroutine starts with a tiny stack (a few kilobytes) that grows and shrinks as needed, rather than the fixed, typically megabyte-sized stack an OS thread reserves. This is why it’s completely normal for a Go program to spin up tens of thousands of goroutines, something that would exhaust memory quickly with OS threads.
The scheduler moves goroutines on and off OS threads cooperatively at well-defined points: function calls, channel operations, blocking I/O, select statements, and calls into the sync package. When a goroutine blocks — say, waiting on a channel receive — the runtime parks it and frees up the underlying OS thread to run a different goroutine. This is what makes goroutines cheap: blocking one doesn’t waste an OS thread the way it would with traditional thread-per-request designs.
Critically, go gives you no built-in way to know when the goroutine finishes, and no way to directly retrieve a return value from it. The main function does not wait for goroutines it starts — when main returns, the program exits immediately, even if other goroutines are mid-flight. Coordinating goroutines (waiting for them, collecting their results, handling their errors) is done explicitly, using tools like sync.WaitGroup and channels, covered in later lessons and used throughout the examples below.
Syntax
The general form is simply the go keyword followed by a function call:
go f(x, y)
go func(a int) {
// use a here
}(x)
- go — the keyword that triggers concurrent execution; it must be immediately followed by a function call.
- f(x, y) — any function or method call: a named function, a method value, or an anonymous function literal that is invoked immediately.
- Arguments — evaluated synchronously, in the calling goroutine, at the moment the
gostatement runs — before the new goroutine actually starts executing. - Return values — a goroutine’s function cannot return a value to the caller directly;
go f()is not an expression, so you cannot writeresult := go f(). To get data out, send it over a channel or write it to a shared variable that you protect with a mutex.
Examples
Example 1: A single goroutine with a WaitGroup
The simplest useful pattern pairs go with a sync.WaitGroup so main waits for the goroutine to finish before exiting.
package main
import (
"fmt"
"sync"
)
func sayHello(name string, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Println("Hello from", name)
}
func main() {
var wg sync.WaitGroup
wg.Add(1)
go sayHello("goroutine", &wg)
wg.Wait()
fmt.Println("main done")
}
Hello from goroutine
main done
wg.Add(1) tells the WaitGroup to expect one goroutine to finish. Inside sayHello, defer wg.Done() decrements that counter when the function returns. wg.Wait() in main blocks until the counter reaches zero, guaranteeing the goroutine has finished before main prints "main done" and the program exits.
Example 2: Multiple goroutines sharing state safely
Here, five goroutines each add a number to a shared total. Because multiple goroutines write to the same variable, a sync.Mutex protects it from a data race.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
var mu sync.Mutex
total := 0
for i := 1; i <= 5; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
mu.Lock()
total += n
mu.Unlock()
}(i)
}
wg.Wait()
fmt.Println("total:", total)
}
total: 15
Each goroutine gets its own copy of i because it’s passed in as the parameter n, rather than captured from the surrounding loop — this sidesteps the classic loop-variable bug covered in Common Mistakes below. The goroutines can run in any order the scheduler chooses, but because each one locks the mutex before touching total, the final sum is always correct and deterministic: 1+2+3+4+5 = 15.
Example 3: Collecting results over a channel
A more realistic pattern: goroutines do work and send results back on a channel instead of touching shared state directly.
package main
import (
"fmt"
"sort"
"sync"
)
func square(n int, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
results <- n * n
}
func main() {
numbers := []int{1, 2, 3, 4, 5}
results := make(chan int, len(numbers))
var wg sync.WaitGroup
for _, n := range numbers {
wg.Add(1)
go square(n, results, &wg)
}
wg.Wait()
close(results)
var squares []int
for r := range results {
squares = append(squares, r)
}
sort.Ints(squares)
fmt.Println(squares)
}
[1 4 9 16 25]
Each call to square runs in its own goroutine and sends one value into the buffered channel results. The channel is buffered with capacity equal to the number of goroutines, so every send succeeds without blocking. After wg.Wait() confirms all sends have happened, close(results) lets the for r := range results loop drain the channel and terminate. The results are sorted before printing because goroutines can finish in any order — sorting makes the output deterministic regardless of scheduling.
How It Works Step by Step
Walking through Example 3 in order:
- The
forloop runs in the main goroutine. On each iteration, it callswg.Add(1)synchronously, then executesgo square(n, results, &wg). The argumentsn,results, and&wgare evaluated immediately; the call itself is queued to run concurrently. - The main goroutine does not wait between iterations — it queues all five goroutines essentially back to back, then reaches
wg.Wait(). - The Go scheduler distributes the five goroutines across available OS threads (however many
GOMAXPROCSallows). Each one computesn * nand sends it into the buffered channel, which does not block since the buffer has room. - Each goroutine’s deferred
wg.Done()fires as it returns, decrementing the WaitGroup’s counter. - Once all five goroutines have called
Done,wg.Wait()unblocks in the main goroutine, which then closes the channel and drains it.
Common Mistakes
Mistake 1: Not waiting for goroutines to finish
main exiting does not wait for goroutines you’ve started — the whole program terminates the instant main returns, goroutines mid-execution or not.
package main
import "fmt"
func main() {
go fmt.Println("Hello from goroutine")
fmt.Println("main done")
}
This is unpredictable: it often just prints "main done", because main can reach its end and exit before the scheduler ever gets around to running the new goroutine. The fix is to make main wait explicitly, typically with a sync.WaitGroup:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("Hello from goroutine")
}()
wg.Wait()
fmt.Println("main done")
}
Hello from goroutine
main done
Mistake 2: Capturing the loop variable instead of passing it as a parameter
Before Go 1.22, a goroutine launched inside a for loop that referenced the loop variable directly captured the same shared variable across every iteration, not a fresh copy per iteration:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(i)
}()
}
wg.Wait()
}
On Go versions before 1.22, this frequently printed 3 3 3 instead of 0 1 2, because all three goroutines closed over the same i, which had already reached 3 by the time any of them ran. Go 1.22 changed the language so each loop iteration gets its own copy of i, which fixes this specific case — but passing the value in explicitly as a parameter is still the clearer, more portable, and defensive style, since it works identically on every Go version and makes the capture obvious at the call site:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
results := make([]int, 3)
for i := 0; i < 3; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
results[i] = i * i
}(i)
}
wg.Wait()
fmt.Println(results)
}
[0 1 4]
Best Practices
- Always have an explicit plan for how a goroutine finishes — a
WaitGroup, a channel close, or acontext.Contextcancellation. A goroutine with no exit condition is a leak. - Never let a goroutine directly write to a return value or shared variable without synchronization (a mutex or a channel); the Go race detector (
go run -race) will catch most of these bugs during development. - Pass loop variables and other per-iteration state into a goroutine as explicit function parameters, even on Go 1.22+, so the code is unambiguous and portable.
- Prefer channels for handing data between goroutines and mutexes for protecting small shared pieces of state; don’t reach for both when one would do.
- Keep goroutine functions small and focused — a goroutine that panics and isn’t recovered will crash the entire program, not just itself.
- Avoid launching an unbounded number of goroutines from user input or a loop over an unknown-size collection without a worker pool or semaphore to cap concurrency.
Practice Exercises
- Write a program that launches five goroutines, each printing its own goroutine number (0 through 4) passed in as a parameter, and uses a
sync.WaitGroupsomainwaits for all of them before exiting. - Modify Example 3 so that instead of squaring numbers, each goroutine computes whether its number is prime, and collect the primes into a sorted slice using the same WaitGroup-plus-channel pattern.
- Deliberately reproduce the loop-variable mistake from Common Mistakes, run it a few times with
go run -race, and then fix it using the explicit-parameter pattern. Compare the output before and after.
Summary
- The
gokeyword starts a function call as a new, independently scheduled goroutine and returns control to the caller immediately without waiting. - Goroutines are cheap, small-stack units of execution multiplexed by the Go runtime’s scheduler onto a limited number of OS threads — not the same thing as OS threads themselves.
- A
gostatement cannot return a value directly; use a channel or a mutex-protected variable to get data back out. mainexiting terminates the whole program immediately, regardless of goroutines still running — always synchronize with aWaitGroup, channel, orcontext.Context.- Pass per-iteration loop values into goroutines as explicit parameters to avoid the classic loop-variable capture bug, even though Go 1.22+ made per-iteration variables the default.
