Last Updated: February 1, 2026
Go was designed with concurrency as a first-class citizen. The language philosophy is captured in the proverb: "Don't communicate by sharing memory; share memory by communicating."
Every Go program starts with one goroutine, the main goroutine, and can spawn millions more. Creating a goroutine is so cheap (both in time and memory) that you should think of them as lightweight tasks rather than expensive resources.
This chapter teaches you how to create and manage goroutines in practice. We'll cover the go keyword, different patterns for spawning goroutines, waiting for completion, returning results, and handling errors.
go KeywordThe go keyword is all you need to create a goroutine. Place it before a function call, and that function executes concurrently:
When you write go functionName(args), several things happen:
go statement returns immediately, the calling goroutine continuesThe critical thing to understand: the main goroutine doesn't wait. If main finishes, the program exits, killing all other goroutines without letting them complete.
This race to exit is intentional. Go doesn't have daemon goroutines or automatic waiting. You must explicitly coordinate completion using channels, WaitGroups, or other synchronization primitives.
Go provides several patterns for spawning goroutines, each suited to different situations.
The most straightforward approach calls an existing function:
Named functions are ideal when:
Anonymous functions let you define logic inline:
The trailing () is essential, it invokes the function. Without it, you're trying to use the function value with go, which doesn't make sense.
Anonymous functions are useful when:
Warning: Closures capture variables by reference, not by value. This leads to one of Go's most common concurrency bugs:
By the time the goroutines run, the loop has finished and i equals 10. The fix is to pass i as an argument:
Note: Go 1.22 changed loop variable semantics. Starting with Go 1.22, each iteration gets a fresh variable, fixing this bug. However, for backward compatibility and clarity, passing the value explicitly remains good practice.
Methods work the same as functions with go:
When spawning a goroutine with a method:
s in this case) is capturedYou can store a function in a variable and spawn it as a goroutine:
This pattern is useful when:
| Pattern | Use When | Pros | Cons |
|---|---|---|---|
| Named function | Reusable logic, testing needed | Clear, testable, documented | Requires separate definition |
| Anonymous function | One-off logic, needs captured state | Inline, captures scope | Can't test independently, closure bugs |
| Method | Object-oriented design | Natural for types with state | Receiver captured by reference |
| Function variable | Dynamic dispatch, configuration | Flexible | Indirection, harder to trace |
If you're coming from Java, you might expect goroutines to have IDs, names, and priorities. Go intentionally omits these features.
There's no runtime.Goid() function. This is by design. The Go team deliberately avoided exposing goroutine IDs because:
If you absolutely need to identify goroutines for debugging:
This is fragile and slow. For request tracing, use context.Context instead.
Goroutines don't have names. For debugging and tracing:
All goroutines are equal. The scheduler doesn't support priority levels. If you need prioritization:
Unlike Java, there's no distinction between daemon and non-daemon goroutines. When main returns, all goroutines die immediately. If you need background work to continue, keep main running:
The idiomatic replacement for thread-local storage is context.Context:
Context carries request-scoped values, deadlines, and cancellation signals. The next chapters cover context in depth.
Since the go statement returns immediately, you need explicit synchronization to wait for goroutines to complete.
WaitGroup is the standard tool for waiting on multiple goroutines:
Critical pattern: Call Add() before starting the goroutine, not inside it. Otherwise, you have a race between Wait() and Add():
For a single goroutine, a channel can signal completion:
Using struct{} (empty struct) signals without sending data, costing zero bytes. Closing the channel, rather than sending a value, allows multiple receivers to unblock simultaneously.
You'll see time.Sleep in examples, but it's wrong for production code:
Problems:
Use time.Sleep only in examples or tests where exact timing doesn't matter.
Goroutines can't return values directly since go returns immediately. Instead, you communicate results through channels or shared memory.
Channels are Go's preferred mechanism for goroutine communication:
For multiple results:
Sometimes channels add unnecessary complexity. For simple aggregation, shared memory with a mutex works well:
For simple counters, sync/atomic is more efficient:
The golang.org/x/sync/errgroup package combines WaitGroup with error handling:
Key features of errgroup:
ctx.Done()Errors in goroutines require explicit handling. They don't propagate to the parent goroutine automatically.
A panic in one goroutine doesn't crash others (except main):
If any goroutine panics without recovery, the entire program crashes. Use recover() to handle panics within goroutines:
For regular errors (not panics), send them through channels:
When running multiple goroutines, collect all errors:
These patterns appear frequently in Go code. Master them and you'll handle most concurrent scenarios.
For operations where you don't need the result:
Use when:
Goroutine does work, returns result through channel:
The returned channel acts like a "future" or "promise" from other languages.
Fixed number of workers processing a shared queue:
Distribute work to multiple goroutines (fan-out), collect results (fan-in):
These patterns are covered in more depth in the channels chapter.