Go context.Context: Cancellation, Timeouts, and Why the First Argument Matters
context.Context is not just convention. It is the mechanism that prevents goroutine leaks, propagates deadlines across service boundaries, and gives callers control over work they started.
The problem context solves
Without context, blocking operations in Go have no cancellation path. When an HTTP handler spawns a database query and the client disconnects mid-flight, the query keeps running. The goroutine is stuck, the DB connection is held, and your process leaks resources until the query eventually finishes or times out at the database level.
// No way to cancel this if the caller disappears
func fetchUser(id string) (*User, error) {
row := db.QueryRow("SELECT * FROM users WHERE id = $1", id)
var u User
return &u, row.Scan(&u.ID, &u.Name, &u.Email)
}
context.Context gives the caller a handle to signal cancellation or set a deadline. Every standard library function that can block — db.QueryContext, http.NewRequestWithContext, exec.CommandContext — accepts a context for this reason.
func fetchUser(ctx context.Context, id string) (*User, error) {
row := db.QueryRowContext(ctx, "SELECT * FROM users WHERE id = $1", id)
var u User
return &u, row.Scan(&u.ID, &u.Name, &u.Email)
}
When ctx is cancelled — because the HTTP handler returned, a deadline elapsed, or a parent cancelled — the database driver receives the signal and abandons the query. The goroutine returns. The connection is released.
What context.Context actually is
ConceptGo ConcurrencyA context is an immutable value that carries a cancellation signal, an optional deadline, and optional key-value pairs. Cancellation flows down a tree: cancelling a parent cancels all children. Values flow down but not up.
Prerequisites
- Go goroutines
- Go interfaces
- basic net/http
Key Points
- context.Context is an interface with four methods: Deadline, Done, Err, and Value.
- Contexts form a tree. Child contexts inherit the parent's cancellation and deadline.
- A context cannot be uncancelled. Once Done is closed, it stays closed.
- context.Background() is the root — it is never cancelled. Use it at program startup and as the top-level context in tests.
- context.TODO() signals that the correct context is not yet determined. It is a placeholder, not a synonym for Background.
The four constructors and when to use each
WithCancel: manual cancellation
ctx, cancel := context.WithCancel(parent)
defer cancel() // always call cancel, even if you cancel early
go func() {
if err := doWork(ctx); err != nil {
cancel() // signal siblings to stop
}
}()
Use WithCancel when you need explicit control over when work stops — fan-out patterns where one error should abort the rest, or cleanup on early exit.
Always defer cancel. If you forget, the child context is never collected until the parent is cancelled. In a long-running server, this is a slow goroutine leak.
WithTimeout and WithDeadline: time-bounded work
// Timeout: relative duration
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()
// Deadline: absolute time
ctx, cancel := context.WithDeadline(parent, time.Now().Add(5*time.Second))
defer cancel()
Both create a context that cancels automatically when the time expires. The difference is cosmetic: WithTimeout(parent, d) is WithDeadline(parent, time.Now().Add(d)).
In practice, services have a hierarchy of timeouts: an HTTP server sets a per-request deadline, a downstream RPC call sets a shorter deadline. Use WithTimeout on each outbound call so a slow downstream cannot consume the entire request budget.
func (s *Service) GetUserWithPosts(ctx context.Context, userID string) (*UserWithPosts, error) {
// Total request context might have 2s left. Give each call a budget.
userCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()
user, err := s.users.Get(userCtx, userID)
if err != nil {
return nil, fmt.Errorf("user fetch: %w", err)
}
postsCtx, cancel2 := context.WithTimeout(ctx, 800*time.Millisecond)
defer cancel2()
posts, err := s.posts.ListByUser(postsCtx, userID)
if err != nil {
return nil, fmt.Errorf("posts fetch: %w", err)
}
return &UserWithPosts{User: user, Posts: posts}, nil
}
💡Why deadline budgeting matters under load
Without per-call timeouts, a single slow downstream can hold goroutines until the server-level timeout fires. Under load, hundreds of goroutines pile up waiting for the same slow service. This is the mechanism behind cascading failures: one slow dependency degrades your entire server.
Propagating the parent context plus a shorter child timeout bounds the damage. If the parent context has 2 seconds left and you set a child timeout of 500ms, the child call uses whichever deadline is sooner.
WithValue: request-scoped metadata
type ctxKey string
const requestIDKey ctxKey = "request_id"
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey, id)
}
func RequestIDFrom(ctx context.Context) (string, bool) {
id, ok := ctx.Value(requestIDKey).(string)
return id, ok
}
WithValue stores a key-value pair in the context. Every call to ctx.Value(key) walks up the parent chain until a match is found. It is O(n) in the depth of the chain.
Use WithValue for cross-cutting concerns: request IDs, trace spans, authenticated user identity. Do not use it to pass business logic parameters — that makes function signatures implicit and testing harder.
Always define unexported key types (the ctxKey string type above). Using a plain string as a key risks collisions with other packages that use the same string.
⚠The context.Value anti-pattern
Passing a database connection, a logger instance, or a user object via ctx.Value is common and usually wrong. It hides dependencies, defeats static analysis, and makes functions hard to test in isolation. If a function needs a DB, it should accept a DB. Use context values for infrastructure metadata — tracing, request correlation, authentication principals — not for application dependencies.
Checking cancellation in your own loops
Library functions handle context cancellation internally. In your own code — long-running loops, multi-step processing — check ctx.Err() or select on ctx.Done():
func processItems(ctx context.Context, items []Item) error {
for _, item := range items {
// Check before each unit of work
if err := ctx.Err(); err != nil {
return fmt.Errorf("cancelled after %d items: %w", i, err)
}
if err := process(ctx, item); err != nil {
return err
}
}
return nil
}
For concurrent work where you also need to receive results:
select {
case result := <-resultCh:
return result, nil
case <-ctx.Done():
return nil, ctx.Err()
}
ctx.Err() returns context.Canceled if a cancel function was called, or context.DeadlineExceeded if the deadline passed. Wrap these with %w so callers can inspect them with errors.Is.
Passing context as the first argument
The convention is not arbitrary. Functions that accept context.Context as the first argument compose naturally with each other. Any function in the call chain can check for cancellation. The context flows from the outermost handler — an HTTP request, a gRPC call, a CLI invocation — down through every layer.
// Standard chain: context flows inward
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() // net/http sets this per request
user, err := h.service.GetUser(ctx, r.PathValue("id"))
// ...
}
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
return s.repo.FindByID(ctx, id)
}
func (r *UserRepo) FindByID(ctx context.Context, id string) (*User, error) {
return r.db.QueryRowContext(ctx, "SELECT ...")
}
Do not store a context in a struct. A struct-stored context has no lifecycle binding to any particular request or operation. The context becomes stale. Pass it through function arguments every time.
A Go HTTP handler calls two external services in sequence. The first call takes 1.2 seconds; the whole handler has a 2-second timeout. What happens to the second external call if it inherits the parent context?
mediumThe handler receives r.Context() which has a 2-second deadline set by the server. No additional timeout is added for the individual service calls.
AThe second call gets a full 2 seconds because each call resets the deadline
Incorrect.Context deadlines do not reset. A child context inherits the parent deadline and can only set an earlier one, never a later one.BThe second call has roughly 800ms before the parent context expires
Correct!After the first call consumes 1.2s, only ~800ms remain on the parent deadline. The second call will be cancelled when that window closes, regardless of the second service's own behavior.CThe second call is unaffected because it uses a different connection
Incorrect.Context cancellation is independent of the connection layer. The context is checked by the HTTP client, database driver, or any other blocking operation that accepts it.DThe handler should ignore context and set absolute timeouts per call
Incorrect.Per-call timeouts are good practice, but they should be applied as children of the parent context, not as replacements. Ignoring the parent means you cannot cascade cancellation from the caller.
Hint:Context deadlines are inherited and reduce monotonically. They are never extended by a child.