The signal to stop
You are running a long task. A database query, a file download, a batch of API calls. The user loses interest and closes the browser tab. Or the load balancer marks your instance unhealthy and sends a shutdown signal. The task keeps running. It holds onto memory, blocks a database connection, and writes to a log file that no one reads. You need a way to tell that task to stop, immediately and gracefully.
Go solves this with context.Context. Think of a context as a signal wire that runs through your code. It carries deadlines, cancellation signals, and request-scoped values. When the signal trips, every part of your program listening to that wire knows it's time to stop. The context doesn't do the work. It just tells the work to stop. The work has to check the signal and bail out.
Contexts form a tree. The root is context.Background(). Every child inherits the parent's deadline and cancellation. If the parent cancels, all children cancel. This makes it easy to cancel a whole subtree of work with one call. You create a context at the top of a call chain, pass it down to every function that needs it, and call a cancel function when the job is done or goes wrong.
Context is a signal. The code must listen.
Minimal cancellation
Here's the simplest pattern: create a context, spawn a goroutine, cancel it, and watch the goroutine exit. The worker function listens on ctx.Done(), which is a channel that closes when the context is canceled.
The worker function needs to listen for the cancellation signal.
// worker runs until the context is canceled.
func worker(ctx context.Context) {
for {
select {
case <-ctx.Done():
// Exit as soon as the context is canceled.
fmt.Println("Stopped:", ctx.Err())
return
case <-time.After(500 * time.Millisecond):
// Simulate a small unit of work.
fmt.Println("Working...")
}
}
}
The caller creates the context and triggers cancellation.
func main() {
// WithCancel creates a context and a cancel function.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go worker(ctx)
time.Sleep(1 * time.Second)
cancel()
time.Sleep(100 * time.Millisecond)
}
The select statement blocks until one of its cases is ready. When cancel() runs, it closes the channel behind ctx.Done(). A closed channel is always ready to receive, so the case <-ctx.Done() branch fires immediately. The worker prints the error from ctx.Err() and returns. The error is context.Canceled.
Calling cancel() is idempotent. You can call it multiple times without error. The first call does the work; subsequent calls do nothing. Always defer the cancel function right after creating the context. This ensures the context resources are released even if the function returns early or panics.
Context flows down. Errors bubble up.
Realistic usage
In a web server, the request context is the standard way to handle client disconnects. The http package creates a context for every request. It cancels that context when the client closes the connection or when the server times out the request. Your handler should pass this context to any downstream work.
The fetchData function performs work and stops if the context is canceled.
// fetchData performs work and stops if the context is canceled.
func fetchData(ctx context.Context) ([]byte, error) {
// Create a channel to simulate an async I/O result.
resultCh := make(chan []byte)
go func() {
time.Sleep(2 * time.Second)
resultCh <- []byte("data")
}()
select {
case <-ctx.Done():
// Client disconnected or deadline exceeded.
return nil, ctx.Err()
case data := <-resultCh:
return data, nil
}
}
The handler uses the request context to drive the work.
func handler(w http.ResponseWriter, r *http.Request) {
// r.Context() cancels when the client closes the connection.
ctx := r.Context()
data, err := fetchData(ctx)
if err != nil {
http.Error(w, err.Error(), 503)
return
}
w.Write(data)
}
If the client disconnects while fetchData is waiting, ctx.Done() becomes ready. The function returns ctx.Err(), which is context.Canceled. The handler sees the error and sends a 503 response. The goroutine inside fetchData might still send to resultCh, but the receiver has already returned. The send will block until the goroutine exits or the channel is garbage collected. In production code, you'd want to ensure that background goroutine also respects the context to avoid leaks.
Notice the error check. Go forces you to handle errors explicitly. if err != nil is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. You can't accidentally ignore a cancellation error. If you try to use a variable without checking the error, the compiler won't stop you, but the logic will be wrong.
Respect the request context.
Pitfalls and conventions
Goroutine leaks happen when a goroutine waits on a channel that never gets closed. If you start a goroutine and forget to cancel its context, that goroutine might block forever. The memory for that goroutine stays allocated. Over time, these leaks consume resources and can crash the service.
A common mistake is storing a context inside a struct. Contexts are for passing through call stacks, not for state. If you put a context in a struct, you couple the struct's lifetime to the context's lifetime in a way that breaks the tree model. Pass the context to the function that starts the work instead.
Another pitfall is abusing context.WithValue. Contexts can carry values, like user IDs or trace IDs. This is useful for request-scoped data. It is not useful for passing optional parameters or caching data. Values in a context are immutable and shared by all children. If you use values for performance, you'll end up with code that's hard to test and reason about. Use WithValue only for metadata that needs to flow through the call stack without adding parameters to every function.
The compiler catches some mistakes. If you forget to import the context package, you get undefined: context. If you pass a string where a context is expected, the compiler rejects the program with cannot use "value" (untyped string constant) as context.Context value in argument. If you typo the variable name, you get undefined: ctx. These errors are clear. The runtime errors are trickier. Passing a nil context causes a panic when the code tries to call ctx.Done(). The compiler won't catch this. Always derive from context.Background() or context.TODO().
Convention matters in Go. The receiver name is usually one or two letters matching the type: (b *Buffer) Write(...), not (this *Buffer). Public names start with a capital letter. Private names start lowercase. There are no keywords like public or private. Visibility is controlled by capitalization. Trust gofmt. Argue logic, not formatting. Most editors run gofmt on save, and the Go community expects code to be formatted by the tool.
The worst goroutine bug is the one that never logs.
When to use context
Use context.Background() when you need a root context for a new process or top-level function.
Use context.WithCancel when you want to cancel a task manually from another goroutine.
Use context.WithTimeout when a task must finish within a specific duration, like a database query or an external API call.
Use context.WithDeadline when you have an absolute time by which the work must complete, such as syncing with a scheduled batch job.
Use context.WithValue sparingly to pass request-scoped data like user IDs or trace IDs, never for performance caching or optional parameters.
Use a channel instead of context when you need to send data between goroutines, not just signals.
Use sync.WaitGroup to wait for goroutines to finish, but always combine it with context to avoid leaks.
Context is plumbing. Run it through every long-lived call site.