Skip to main content
Technology & EngineeringGo257 lines

Context Patterns

Context usage for cancellation, timeouts, deadlines, and value propagation in Go

Quick Summary18 lines
You are an expert in Go's `context` package, helping developers correctly use contexts for cancellation, deadlines, timeouts, and request-scoped value propagation.

## Key Points

- `context.Background()` — top-level context for main, init, and tests.
- `context.TODO()` — placeholder when unsure which context to use.
- `context.WithCancel(parent)` — returns a context cancelled when `cancel()` is called.
- `context.WithTimeout(parent, duration)` — cancelled after the duration.
- `context.WithDeadline(parent, time)` — cancelled at a specific time.
- `context.WithValue(parent, key, val)` — carries a key-value pair.
- `context.WithoutCancel(parent)` — (Go 1.21+) derives a context that is not cancelled when the parent is.
- Pass `context.Context` as the first parameter of every function that does I/O or may block: `func Foo(ctx context.Context, ...) error`.
- Always call the `cancel` function returned by `WithCancel`/`WithTimeout`/`WithDeadline`, typically with `defer cancel()`.
- Use `context.WithValue` only for request-scoped data that transits process boundaries (request IDs, auth tokens), not for passing function parameters.
- Use typed, unexported keys for context values to avoid collisions.
- Prefer `signal.NotifyContext` for OS signal handling instead of manual channel setup.
skilldb get go-skills/Context PatternsFull skill: 257 lines
Paste into your CLAUDE.md or agent config

Context Patterns — Go Programming

You are an expert in Go's context package, helping developers correctly use contexts for cancellation, deadlines, timeouts, and request-scoped value propagation.

Core Philosophy

Context is Go's answer to the question of how to manage the lifecycle of operations that span multiple goroutines, network calls, and service boundaries. Rather than relying on global timeouts or ad-hoc cancellation flags, the context package gives every operation an explicit scope — a parent-child tree where cancellation flows downward and deadlines are inherited. This design makes it possible to tear down an entire request's worth of concurrent work with a single cancel() call, without any goroutine needing to know about the others.

The key insight behind context is that every piece of work in a server should be tied to the request that initiated it. When a client disconnects or a deadline passes, all downstream operations — database queries, RPC calls, file reads — should stop promptly. Context makes this cooperative: functions check ctx.Done() or pass the context to libraries that do. This is not automatic garbage collection of goroutines; it is an explicit contract that every layer of your code opts into by accepting and forwarding a context.Context.

Context values are the most misunderstood part of the package. They exist for request-scoped metadata that crosses API boundaries — trace IDs, authentication tokens, request identifiers — not for passing function arguments or configuration. When you find yourself reaching for context.WithValue, ask whether the data is truly request-scoped and whether it needs to transit process boundaries. If not, pass it as a regular function parameter instead.

Anti-Patterns

  • Using context for dependency injection: Stuffing database connections, loggers, or service clients into context values turns your function signatures into lies — the real dependencies are hidden inside an opaque context.Context instead of being visible in the parameter list. Pass dependencies explicitly through constructors or function parameters.

  • Creating a fresh context.Background() deep in the call stack: This severs the cancellation chain from the original request context. When the parent request is cancelled, your downstream operation keeps running because it has no idea the parent is gone. Always derive from the context passed to you.

  • Ignoring context in CPU-bound loops: A function that accepts a context but never checks ctx.Done() during a long computation defeats the purpose of context-based cancellation. Periodically check the context in tight loops, even if it means adding a check every N iterations.

  • Storing mutable state in context values: Context values should be immutable, read-only data. Storing a pointer to a mutable struct and modifying it from multiple goroutines introduces race conditions that the context package was never designed to protect against.

  • Letting context.TODO() persist into production code: TODO is a development placeholder signaling "I haven't decided which context to use yet." Shipping it means you skipped the decision. Every TODO should be replaced with a real context derived from the caller before the code is merged.

Overview

The context package provides a standard way to carry deadlines, cancellation signals, and request-scoped values across API boundaries and between goroutines. Almost every Go function that performs I/O or long-running work should accept a context.Context as its first parameter.

Core Concepts

Context Tree

Contexts form a tree rooted at context.Background() (or context.TODO()). When a parent context is cancelled, all derived child contexts are also cancelled.

Context Types

  • context.Background() — top-level context for main, init, and tests.
  • context.TODO() — placeholder when unsure which context to use.
  • context.WithCancel(parent) — returns a context cancelled when cancel() is called.
  • context.WithTimeout(parent, duration) — cancelled after the duration.
  • context.WithDeadline(parent, time) — cancelled at a specific time.
  • context.WithValue(parent, key, val) — carries a key-value pair.
  • context.WithoutCancel(parent) — (Go 1.21+) derives a context that is not cancelled when the parent is.

ctx.Done()

Returns a channel that is closed when the context is cancelled or times out. Use in select to stop work.

ctx.Err()

Returns context.Canceled or context.DeadlineExceeded after the context is done.

Implementation Patterns

Cancellation

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    go worker(ctx)

    time.Sleep(3 * time.Second)
    cancel() // signal worker to stop
    time.Sleep(100 * time.Millisecond) // let worker clean up
}

func worker(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            slog.Info("worker stopping", "reason", ctx.Err())
            return
        default:
            doWork()
        }
    }
}

Timeout

func fetchData(ctx context.Context, url string) ([]byte, error) {
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()

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

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, fmt.Errorf("executing request: %w", err)
    }
    defer resp.Body.Close()

    return io.ReadAll(resp.Body)
}

Deadline

func processJob(ctx context.Context, job Job) error {
    deadline := time.Now().Add(30 * time.Second)
    ctx, cancel := context.WithDeadline(ctx, deadline)
    defer cancel()

    // Check remaining time
    if dl, ok := ctx.Deadline(); ok {
        remaining := time.Until(dl)
        slog.Info("time budget", "remaining", remaining)
    }

    return job.Execute(ctx)
}

Context Values (Request-Scoped Data)

// Use unexported key types to prevent collisions
type contextKey string

const (
    requestIDKey contextKey = "requestID"
    userKey      contextKey = "user"
)

// Setter
func WithRequestID(ctx context.Context, id string) context.Context {
    return context.WithValue(ctx, requestIDKey, id)
}

// Getter
func RequestIDFrom(ctx context.Context) (string, bool) {
    id, ok := ctx.Value(requestIDKey).(string)
    return id, ok
}

// Middleware
func requestIDMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id := r.Header.Get("X-Request-ID")
        if id == "" {
            id = uuid.NewString()
        }
        ctx := WithRequestID(r.Context(), id)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Database Operations with Context

func (r *UserRepo) GetByID(ctx context.Context, id int64) (*User, error) {
    query := `SELECT id, name, email FROM users WHERE id = $1`

    var u User
    err := r.db.QueryRowContext(ctx, query, id).Scan(&u.ID, &u.Name, &u.Email)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, ErrNotFound
        }
        return nil, fmt.Errorf("querying user %d: %w", id, err)
    }
    return &u, nil
}

Graceful Shutdown with Context

func main() {
    ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
    defer stop()

    srv := &http.Server{Addr: ":8080", Handler: mux}

    go func() {
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            slog.Error("server error", "err", err)
        }
    }()

    <-ctx.Done()
    slog.Info("shutting down")

    shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
    defer cancel()

    if err := srv.Shutdown(shutdownCtx); err != nil {
        slog.Error("shutdown error", "err", err)
    }
}

Context-Aware Long-Running Loop

func poll(ctx context.Context, interval time.Duration, fn func(context.Context) error) error {
    ticker := time.NewTicker(interval)
    defer ticker.Stop()

    for {
        if err := fn(ctx); err != nil {
            return err
        }

        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-ticker.C:
        }
    }
}

AfterFunc (Go 1.21+)

ctx, cancel := context.WithCancel(parentCtx)
defer cancel()

stop := context.AfterFunc(ctx, func() {
    // Runs in a new goroutine when ctx is cancelled
    conn.Close()
})
// Call stop() if you want to prevent the callback from firing
defer stop()

Best Practices

  • Pass context.Context as the first parameter of every function that does I/O or may block: func Foo(ctx context.Context, ...) error.
  • Always call the cancel function returned by WithCancel/WithTimeout/WithDeadline, typically with defer cancel().
  • Use context.WithValue only for request-scoped data that transits process boundaries (request IDs, auth tokens), not for passing function parameters.
  • Use typed, unexported keys for context values to avoid collisions.
  • Prefer signal.NotifyContext for OS signal handling instead of manual channel setup.
  • Never store contexts in structs. Pass them explicitly through function calls.
  • Use context.WithoutCancel (Go 1.21+) when a child operation must outlive the parent (e.g., flushing logs on shutdown).

Common Pitfalls

  • Not checking ctx.Err() in loops: a long computation loop should periodically check if the context is cancelled.
  • Forgetting defer cancel(): leaks resources (timers, goroutines) held by the context.
  • Using context.Background() everywhere: bypasses cancellation propagation. Accept a context from the caller.
  • Using context.TODO() in production: it is a development marker; replace it with a real context before shipping.
  • Storing large objects in context values: context values are copied on derivation; keep them small (strings, small structs).
  • Expecting context values to be type-safe: ctx.Value returns any; always type-assert and handle the missing-value case.
  • Creating a new context.Background() in middleware: this breaks the parent chain. Derive from r.Context() instead.

Install this skill directly: skilldb add go-skills

Get CLI access →