Learn how Go context handles cancellation, deadlines, and request-scoped values without leaking goroutines or hiding dependencies.
Last updated on

Context in Go: Cancellation, Timeouts, and Values


Go’s context package carries cancellation signals, deadlines, and request-scoped values across API boundaries. Its most important job is cancellation: when a request ends, the goroutines and network calls started for that request should stop too.

A context does not cancel work by force. Your code must pass it down and either call APIs that observe it or check ctx.Done() itself.

Pass context explicitly

Accept context.Context as the first parameter. Do not store it in a long-lived struct, and do not pass nil.

func LoadUser(ctx context.Context, id string) (User, error) {
    return repository.LoadUser(ctx, id)
}

This convention keeps cancellation visible in the function signature. It also lets callers control the lifetime of the work. For a closer look at this API convention, read why context should be the first argument.

Use context.Background() at the root of a program. HTTP handlers already receive a request context through r.Context(), so application code normally starts there instead.

func userHandler(w http.ResponseWriter, r *http.Request) {
    user, err := LoadUser(r.Context(), r.PathValue("id"))
    if err != nil {
        http.Error(w, "could not load user", http.StatusInternalServerError)
        return
    }
    _ = json.NewEncoder(w).Encode(user)
}

When the client disconnects or the server cancels the request, r.Context() is cancelled. Passing it into the repository gives database and network calls a chance to stop.

Add a timeout at the boundary

Use context.WithTimeout when an operation must not run indefinitely.

func fetchReport(parent context.Context, client *http.Client, url string) ([]byte, error) {
    ctx, cancel := context.WithTimeout(parent, 2*time.Second)
    defer cancel()

    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return nil, fmt.Errorf("create report request: %w", err)
    }

    resp, err := client.Do(req)
    if err != nil {
        return nil, fmt.Errorf("fetch report: %w", err)
    }
    defer resp.Body.Close()

    return io.ReadAll(resp.Body)
}

Always call the returned cancel function, even when the timeout is expected to fire. Cancelling releases the timer and the resources associated with the child context as soon as the function finishes.

Put timeouts at boundaries where you understand the latency budget. Adding a fresh five-second timeout inside every helper can accidentally let a larger operation run far longer than intended.

Stop goroutines when context is cancelled

Long-running goroutines should select on ctx.Done() alongside their normal work.

func consume(ctx context.Context, jobs <-chan Job) error {
    for {
        select {
        case <-ctx.Done():
            return ctx.Err()
        case job, ok := <-jobs:
            if !ok {
                return nil
            }
            if err := process(ctx, job); err != nil {
                return err
            }
        }
    }
}

Avoid a default branch unless you genuinely need non-blocking behavior. A loop containing select, default, and no blocking operation can consume an entire CPU core. Many common goroutine leaks come from a sender or receiver that has no cancellation path.

Inspect cancellation errors

ctx.Err() returns context.Canceled when cancellation was explicit and context.DeadlineExceeded when a deadline expired. Use errors.Is when the error may have been wrapped.

if errors.Is(err, context.DeadlineExceeded) {
    metrics.Increment("report_timeout")
}

context.WithCancelCause lets the cancelling code attach a more useful reason. Call context.Cause(ctx) to retrieve it while preserving normal ctx.Err() behavior for existing callers.

ctx, cancel := context.WithCancelCause(parent)
cancel(errors.New("account was disabled"))

fmt.Println(ctx.Err())          // context canceled
fmt.Println(context.Cause(ctx)) // account was disabled

For broader error design guidance, see error handling best practices in Go.

Use context values sparingly

Context values are for request-scoped metadata that crosses process or API boundaries, such as a trace ID. They are not a replacement for function parameters or dependency injection.

Use an unexported key type to avoid collisions:

type traceIDKey struct{}

func WithTraceID(ctx context.Context, traceID string) context.Context {
    return context.WithValue(ctx, traceIDKey{}, traceID)
}

func TraceID(ctx context.Context) (string, bool) {
    traceID, ok := ctx.Value(traceIDKey{}).(string)
    return traceID, ok
}

Do not use string keys shared across packages, and do not put optional configuration or service clients in a context.

Test cancellation deliberately

Tests should trigger the behavior they need rather than waiting for a long real timeout.

func TestConsumeStopsWhenCancelled(t *testing.T) {
    ctx, cancel := context.WithCancel(context.Background())
    cancel()

    err := consume(ctx, make(chan Job))
    if !errors.Is(err, context.Canceled) {
        t.Fatalf("expected context.Canceled, got %v", err)
    }
}

Use a short timeout as a final safety net, not as the main synchronization mechanism in a test.

The rule worth remembering is simple: the code that starts work owns its lifetime. Create deadlines at meaningful boundaries, pass the context down, and make every long-running operation provide a cancellation path. The context package documentation is the authoritative reference for the full API.