Go Context API: Timeouts, Cancellation & Values

Go Context API: The Propagation Mirror
The Go context package provides a standard way to carry deadlines, cancellation signals, and request-scoped values across API boundaries and goroutines. A context.Context is passed as the first argument to any function that performs I/O, spawns goroutines, or calls external services. When the context is cancelled or times out, all operations watching it via ctx.Done() should stop and return immediately.
The Context API: Managing Lifecycles
In production-grade Go applications, particularly web servers and microservices, you rarely launch a Goroutine and let it run forever. What happens if a user cancels their HTTP request? Or what if a database query takes too long and you need to abort it?
The context package provides the standard way to carry deadlines, cancellation signals, and other request-scoped values across API boundaries and between processes.
Almost all major Go libraries—from standard net/http to database drivers like GORM or Ent—accept a Context as their first argument. It is the thread that binds the entire Go ecosystem together.
1. The Cancellation Mirror: Tree Propagation Physics
A Context is not a flat object; it is a Node in a Tree.
The Lifecycle Physics
- The Parent/Child Mirror: When you create a context (e.g.,
WithCancel), you are adding a leaf to a global execution tree. - The Broadcast Mirror: When a parent context is cancelled, Go closes a single channel (
Done()). Because multiple goroutines areselecting on this same channel, the signal is "Broadcast" to every child node simultaneously in O(1) time. - The Result: You can stop 10,000 goroutines across 50 layers of your application by calling one function, ensuring that your CPU doesn't waste cycles on "Zombie Work" for a request that has already been discarded.
2. The Basic Context Pattern
A Context is immutable. To add behavior (like a timeout or cancellation), you use "derivation" functions that return a new child Context and a CancelFunc.
func main() {
// Create a base background context
ctx := context.Background()
// Derive a context that cancels after 2 seconds
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel() // Always call cancel to release resources!
// Pass ctx to a long-running operation
select {
case <-time.After(3 * time.Second):
fmt.Println("Overslept")
case <-ctx.Done():
// This will trigger after 2 seconds
fmt.Println("Context cancelled:", ctx.Err())
}
}Carrying Values Beyond Data
Wait, context can also carry values. This is used for "metadata" that is secondary to the primary function arguments, such as Trace IDs, User authentication tokens, or Logger instances.
Do not use
context.WithValueto pass optional function parameters. Use it only for data that must transit across API boundaries and across the entire call stack.
3. The Value Mirror: The Linked-List Cost of Secrecy
context.WithValue is often misunderstood as a "Global Map," but its hardware implementation is very different.
The Metadata Physics
- The Linked-List Search: Every time you add a value to a context, you wrap the existing context in a new struct. Looking up a value (
ctx.Value(key)) requires Go to walk backwards up the tree, checking every node. - The Performance Mirror: This search is O(N). If you have a deep call stack and many context values, lookup latency increases linearly.
- The Architecture: This is why Context should only store metadata (Trace IDs, Loggers). For high-frequency application data, use explicit struct parameters to avoid the linked-list traversal overhead.
Carrying Values Beyond Data
type key string
const requestIDKey key = "reqID"
func main() {
ctx := context.WithValue(context.Background(), requestIDKey, "12345")
processRequest(ctx)
}
func processRequest(ctx context.Context) {
if id, ok := ctx.Value(requestIDKey).(string); ok {
fmt.Println("Processing Request ID:", id)
}
}Propagation through Layers
When you cancel a parent context, Go automatically cancels all its children. This allows you to stop an entire tree of Goroutines with a single signal.
Context Variants
Best Practices
- Always call cancel(): If you use
WithCancel,WithDeadline, orWithTimeout, always call the cancel function (usually viadefer) to prevent resource leaks. - Context should be the first argument: By convention,
context.Contextshould be the first parameter of any function that performs I/O or takes time to complete. - Don't store context in structs: Pass it explicitly through the call stack.
Context in HTTP Handlers
The most common real-world use of context is in HTTP handlers. Go's net/http package automatically attaches a context to every incoming request. When the client disconnects, the context is automatically cancelled. Your handler should respect this:
func searchHandler(w http.ResponseWriter, r *http.Request) {
// r.Context() is already cancelled if the client disconnects
ctx := r.Context()
// Add a timeout for the database query specifically
queryCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
results, err := db.QueryContext(queryCtx, "SELECT * FROM products WHERE name LIKE $1", "%phone%")
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "Search timed out", http.StatusGatewayTimeout)
return
}
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
defer results.Close()
// Render results...
}This pattern ensures that if a user closes their browser tab, the expensive database query is cancelled immediately, freeing up your database connection pool.
Context Propagation Through Layers
A key strength of the context design is how cancellation propagates through your entire call stack automatically. Consider a three-layer architecture:
HTTP Handler → Service Layer → Repository Layer → Database
ctx → ctx → ctx → ctxIf the HTTP handler's context is cancelled (client disconnect or timeout), the cancellation propagates down through every layer that received and used that context — the service call stops, the repository query stops, the database driver connection is released.
This tree-based cancellation is why context is the first argument of virtually every meaningful function in production Go code. Libraries like database/sql, net/http, google.golang.org/grpc, and Redis clients all accept context as their first parameter.
Common Mistakes with Context
1. Ignoring ctx.Done()
If your long-running goroutine doesn't listen on ctx.Done(), it will keep running even after the context is cancelled:
// BAD: This goroutine ignores cancellation
go func() {
for {
result := expensiveWork() // Keeps running even after context cancel
results <- result
}
}()
// GOOD: Check ctx.Done() on each iteration
go func() {
for {
select {
case <-ctx.Done():
return // Stop immediately
default:
result := expensiveWork()
results <- result
}
}
}()2. Storing Context in Structs
The Go team explicitly advises against storing context in a struct field. Context is request-scoped and should be passed explicitly through the call stack, not stored for later use:
// BAD
type Server struct {
ctx context.Context // Don't do this
}
// GOOD: Pass context through method parameters
func (s *Server) Process(ctx context.Context, data []byte) error { ... }Phase 14: Lifecycle & Context Mastery Checklist
- Verify Cancellation Propagation: Audit your service layers to ensure that the inbound
Contextis passed to every downstream database and network call. - Audit Value Usage: Identify if
WithValueis being used for high-frequency business logic. Refactor to explicit parameters if O(N) lookup latency is detected. - Implement Deadline Awareness: Never rely on default timeouts. Always wrap inbound contexts in
WithTimeoutwhen calling external APIs to protect your system's stability mirror. - Test Context Leakage: Verify that every
CancelFuncreturned by derivation functions is called (viadefer) to release timer artifacts in the Go runtime. - Use Typed Keys: Replace string-based context keys with unexported
type key struct{}to prevent cross-package value collisions.
Read next: Go Standard Library: The Built-in Toolchain Mirror →
---
## Further Reading
- <a href="https://pkg.go.dev/context" target="_blank" rel="noopener noreferrer">context package — Go standard library docs</a>
- <a href="https://go.dev/blog/context" target="_blank" rel="noopener noreferrer">Go Concurrency Patterns: Context — The Go Blog</a>
Context is the bridge between concurrency and lifecycle management. For the foundational concurrency concepts, see [goroutines](/blog/go-concurrency-goroutines), [channels](/blog/go-concurrency-channels), and [select & WaitGroups](/blog/go-concurrency-select-waitgroups). For applying context to real database queries, see [Go database connectivity with SQL and GORM](/blog/go-database-sql-gorm).
---
## Best Practices for Using Context Effectively
The `context` package is simple in API but nuanced in practice. Following these guidelines keeps your concurrent Go code correct and maintainable.
**1. Pass context as the first argument, always.** Every function that performs I/O, calls an external service, or spawns goroutines should accept a `context.Context` as its first parameter — named `ctx` by convention. This is not merely a style preference; it is mandated by the [Go documentation at go.dev/doc](https://go.dev/doc/), which states that context should be the first parameter and never stored in a struct field.
**2. Always call the cancel function.** Functions like `context.WithTimeout` and `context.WithCancel` return both a derived context and a `CancelFunc`. If you never call the cancel function, Go leaks the internal timer goroutine and the context's resources until the parent is cancelled. Use `defer cancel()` immediately after the context is created.
**3. Set timeouts at the boundary, not deep inside.** Apply `context.WithTimeout` at the outermost call point — the HTTP handler — rather than deep in the repository layer. Inner functions should receive and respect the context they are given. This keeps timeout policy in one place and prevents contradictory timeouts layered on top of each other.
**4. Use typed keys for `WithValue`.** Using built-in types (like `string`) as context keys creates collision risk when two packages store different values under the same key string. Define a private key type per package: `type contextKey string`. This guarantees uniqueness across packages without coordination.
**5. Check `ctx.Err()` in long loops.** If your goroutine runs a loop that does work without any channel operations, it has no way to notice context cancellation unless it explicitly checks `ctx.Err()` or selects on `ctx.Done()`. Add a `select` with a `ctx.Done()` case at the top of each loop iteration for long-running work.
**6. Propagate context to all downstream calls.** A context that reaches a database query but not a Redis call means the Redis call continues after the request is cancelled, wasting resources. Pass the same context object to every I/O call in the request path. Libraries like `database/sql`, `net/http`, and `go-redis` all accept context as their first argument, as documented in the [Go standard library reference](https://pkg.go.dev/std).
**7. Use `context.TODO()` as a clear marker of unfinished work.** When you cannot yet thread a context through part of your code, use `context.TODO()` instead of `context.Background()`. The names are semantically identical at runtime, but `context.TODO()` signals to reviewers and tooling that this code still needs to be wired to a real context — making it easy to find with a code search.
**8. Avoid leaking goroutines by always listening for `ctx.Done()`.** A goroutine that ignores context cancellation continues running after its caller has moved on, holding database connections and memory indefinitely. Every goroutine that outlives a single function call should have a cancellation mechanism, and `ctx.Done()` is the standard one.
## Next Steps
Now that you've mastered the lifecycle of requests and concurrency, we're ready for the most advanced fundamental module. In the next tutorial, we will explore **Reflect and Unsafe**, peering under the hood of Go's type system to understand how highly dynamic libraries are built.
## Common Context Mistakes in Go
**1. Passing `context.Background()` deep into functions**
`context.Background()` is the root context — use it only at the top level (main, test setup, server start). Pass a derived context (with timeout, deadline, or cancellation) into functions so they can respect cancellation signals.
**2. Storing values in context that should be function parameters**
`context.WithValue` is for request-scoped data that crosses API boundaries — request ID, auth token, trace ID. Do not use it as a global variable substitute for ordinary function inputs. The [Go blog on context](https://go.dev/blog/context) gives authoritative guidance.
**3. Not calling the cancel function**
`ctx, cancel := context.WithTimeout(parent, 5*time.Second)` — if you never call `cancel()`, the resources associated with the context leak until the parent is cancelled. Always `defer cancel()` immediately after creating a derived context.
**4. Ignoring `ctx.Done()` in long-running loops**
A function that loops without checking `ctx.Done()` will not respond to cancellation. Add a `select` with `case <-ctx.Done(): return ctx.Err()` at the top of each iteration for long-running work.
**5. Using context values with built-in types as keys**
Using `context.WithValue(ctx, "key", val)` with a string key can collide with keys from other packages. Define a private key type: `type ctxKey struct{}` and use `context.WithValue(ctx, ctxKey{}, val)`.
## Frequently Asked Questions
**What is the difference between `context.WithTimeout` and `context.WithDeadline`?**
`WithTimeout(parent, d)` cancels the context after duration `d` from now. `WithDeadline(parent, t)` cancels at absolute time `t`. `WithTimeout` is syntactic sugar for `WithDeadline(parent, time.Now().Add(d))`. Use `WithTimeout` for "cancel after N seconds" and `WithDeadline` when you have a fixed wall-clock deadline.
**Should I add context to every function?**
Functions that do I/O (HTTP calls, database queries, file reads) should accept a `context.Context` as the first parameter. Pure computation functions generally do not need context. The [Go standard library](https://pkg.go.dev/std) follows this convention consistently.
**How do I propagate context through a goroutine?**
Pass the context as a parameter to the goroutine's function — do not store it in a struct or a global. `go func(ctx context.Context) { ... }(ctx)` is the idiomatic pattern.