When the clock runs out
You are building a service that aggregates data from three different microservices. You set a five-second timeout on the incoming HTTP request. Two of the calls return instantly. The third one hangs. After exactly five seconds, your handler returns a 500 error with the message context deadline exceeded. The request did not fail because the database crashed or the network dropped packets. It failed because the timer fired and the runtime told your code to stop.
The error is not a bug in your logic. It is a boundary being enforced. When you see context deadline exceeded, the Go runtime is surfacing a cancellation signal that originated from a timer. Every function that received that context should have stopped what it was doing and returned immediately. The error is simply the string representation of that cancellation reaching your error-handling path.
Think of it like a kitchen timer on a coffee order. The barista starts pulling the shot when the order comes in. The timer is set for thirty seconds. If the espresso machine is clogged and the shot takes forty seconds, the timer rings. The barista stops the machine, wipes the portafilter, and tells the runner that the order is cancelled. The timer did not break the machine. It just enforced a limit so the kitchen does not stall.
A context is a signal, not a storage bucket.
How contexts actually work
A context.Context is an interface that carries deadlines, cancellation signals, and request-scoped values across API boundaries. In practice, you will almost always use it for the first two. The interface exposes four methods, but you only interact with two of them directly: Done() and Err().
Done() returns a channel that closes when the context is cancelled or its deadline passes. Err() returns a non-nil error explaining why the context was cancelled. If the timer fired, Err() returns context.deadlineExceededError, which prints as context deadline exceeded. If you called cancel() manually, it returns context.canceled.
Contexts form a tree. You start with context.Background() at the top of your call stack. Every time you need a timeout, a manual cancellation, or a deadline, you derive a child context from a parent. When a parent is cancelled, all children are cancelled automatically. This tree structure ensures that a single timeout at the HTTP layer can cleanly unwind through database queries, RPC calls, and background workers without you wiring up manual shutdown logic.
Cancellation is cooperative. The context tells you to stop. Your code has to actually stop.
A minimal example
The following program demonstrates how to create a timeout, pass it down, and check for cancellation inside a long-running loop.
package main
import (
"context"
"fmt"
"time"
)
// FetchData simulates a slow operation that respects context cancellation.
func FetchData(ctx context.Context) error {
// Create a ticker to simulate periodic work.
ticker := time.NewTicker(500 * time.Millisecond)
// Stop the ticker when we exit to prevent resource leaks.
defer ticker.Stop()
for {
select {
// Check if the context was cancelled or timed out.
case <-ctx.Done():
// Return the context error so callers know why we stopped.
return ctx.Err()
// Simulate work progressing in steps.
case <-ticker.C:
fmt.Println("Still working...")
}
}
}
func main() {
// Derive a context that automatically cancels after 2 seconds.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
// Always defer cancel to release the underlying timer goroutine.
defer cancel()
err := FetchData(ctx)
if err != nil {
// Print the exact error string returned by the runtime.
fmt.Printf("Operation failed: %v\n", err)
}
}
Run this program and you will see Still working... print three times, followed by Operation failed: context deadline exceeded. The loop does not hang. The select statement blocks until either the ticker fires or the context channel closes. When the two-second timer expires, ctx.Done() becomes readable, the select jumps to that case, and the function returns immediately.
Context is plumbing. Run it through every long-lived call site.
What happens under the hood
When you call context.WithTimeout(parent, duration), the standard library does three things. It creates a new context node that links to the parent. It spawns a hidden goroutine that runs time.AfterFunc(duration, cancel). It returns a cancel function that you call to clean up.
When the timer fires, the hidden goroutine closes the internal done channel. That single channel close unblocks every select statement waiting on ctx.Done() across your entire call tree. The runtime does not kill goroutines. It does not throw exceptions. It simply makes a channel readable, and your code decides to exit.
This design is intentional. Go avoids preemption for user code. If the runtime could forcibly stop a goroutine, it could leave mutexes locked, files open, or transactions uncommitted. By making cancellation cooperative, you retain full control over cleanup. You close the database connection. You roll back the transaction. You release the lock. Then you return.
If you forget to pass the context to a blocking call, the compiler will reject the program with cannot use ctx (variable of type context.Context) as string value in argument or a similar type mismatch. If you accidentally pass a negative duration to WithTimeout, the runtime panics with negative time interval. These are fast failures that catch mistakes early.
The worst goroutine bug is the one that never logs.
Realistic usage in a handler
In production, contexts flow from the HTTP layer down to storage. The net/http package automatically attaches a context to every incoming request. You extract it with r.Context(), derive child contexts for specific operations, and pass them along.
// GetUser retrieves a user by ID with a strict query timeout.
func GetUser(ctx context.Context, db *sql.DB, id int) (*User, error) {
// Create a child context with a 3-second deadline for this specific query.
queryCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
// Defer cancel to ensure the timer goroutine is cleaned up.
defer cancel()
// Pass the context to the database driver so it can cancel the query.
row := db.QueryRowContext(queryCtx, "SELECT id, name FROM users WHERE id = $1", id)
var u User
err := row.Scan(&u.ID, &u.Name)
if err != nil {
// Wrap the error so callers can distinguish timeouts from other failures.
return nil, fmt.Errorf("query user %d: %w", id, err)
}
return &u, nil
}
Notice the convention: ctx is always the first parameter. Functions that perform I/O accept a context. The receiver name in methods follows the same pattern, usually a short abbreviation like (h *Handler) ServeHTTP(ctx context.Context, w http.ResponseWriter, r *http.Request). The community accepts this boilerplate because it makes the cancellation path explicit.
When the database driver receives queryCtx, it sets up a watch on ctx.Done(). If the three-second timer fires before the query returns, the driver aborts the network read and returns an error. That error bubbles up to row.Scan, which fails. You wrap it with fmt.Errorf and the %w verb. Wrapping preserves the original error chain, which allows callers to use errors.Is(err, context.DeadlineExceeded) to detect timeouts programmatically.
Don't fight the type system. Wrap the value or change the design.
Common pitfalls and how to debug them
The most common mistake is treating context deadline exceeded as a configuration problem. Developers increase the timeout from three seconds to thirty seconds, deploy the change, and call it fixed. The timeout is a symptom, not the cause. The underlying operation is slow because of a missing index, a network partition, or a blocking goroutine. Increasing the timeout just delays the failure and consumes more resources.
Another frequent issue is ignoring ctx.Done() in custom loops or worker goroutines. If you spawn a goroutine to process a queue but never check the context inside the loop, that goroutine will run until the process exits. The context cancellation signal arrives, but nobody is listening. The goroutine leaks. You can catch these leaks with go vet, which warns with context.WithCancel called in a loop or defer missing when it detects suspicious patterns.
Debugging timeouts in production requires tracing the cancellation path. When you see context deadline exceeded in your logs, check three things. First, verify which layer set the deadline. Was it the HTTP server, a retry loop, or a specific database call? Second, check if the error is wrapped. Use errors.Is(err, context.DeadlineExceeded) rather than string matching. Third, add structured logging that records the operation duration alongside the error. If a query consistently takes 4.2 seconds but your timeout is 3.0 seconds, the data tells you exactly what to fix.
If you forget to import the context package, the compiler rejects the program with undefined: context. If you pass a context to a function that expects a string, you get a type mismatch error. These are straightforward. The runtime panic context deadline exceeded is not a panic. It is a regular error value. Treat it like any other error: log it, wrap it, and handle it.
Timeouts are boundaries. Respect them or rewrite the operation.
Choosing the right cancellation pattern
Go provides three ways to derive a context. Pick the one that matches your operational requirement.
Use context.WithTimeout when you know the maximum acceptable duration for an operation. This is the default choice for HTTP requests, database queries, and RPC calls. It automatically cancels after a fixed interval.
Use context.WithDeadline when you need to coordinate multiple operations to finish by a specific wall-clock time. This is useful for batch jobs or scheduled tasks where several steps must complete before midnight or before a cache expires.
Use context.WithCancel when you want to trigger cancellation manually from another goroutine or signal. This fits event-driven workflows where a user clicks "cancel" in a UI, or a health check fails and you need to tear down active connections.
Use context.Background only at the entry point of your program or handler chain. Never derive a timeout from a context that already has a deadline unless you are intentionally shortening it.
Skip contexts entirely when the operation is fast, local, and has no external dependencies. If you are sorting a slice or computing a hash, a context adds overhead without value.