Skip to content
Codeloom
Go

Context Patterns for Production Go Code

Learn production-grade context patterns in Go including timeouts, cancellation propagation, value passing, and middleware integration.

·6 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • How to propagate cancellation through call chains
  • Timeout and deadline patterns for HTTP and database calls
  • When and how to store values in context safely

Prerequisites

  • Basic Go knowledge
  • Familiarity with goroutines and interfaces

The context package is the backbone of production Go code. Every server handler, every database query, every outbound HTTP call should accept a context.Context as its first parameter. This guide covers the patterns you will use daily.

Why Context Matters

A single API request can spawn database queries, cache lookups, and calls to downstream services. If the client disconnects, you want all of that work to stop immediately. Context makes this possible by providing a cancellation signal that propagates through your entire call chain.

func HandleOrder(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context() // carries the client's cancellation signal

    order, err := fetchOrder(ctx, orderID)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    enriched, err := enrichWithInventory(ctx, order)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    json.NewEncoder(w).Encode(enriched)
}

If the client disconnects mid-request, ctx.Done() closes, and any well-behaved downstream function returns early.

Timeouts and Deadlines

context.WithTimeout

Use WithTimeout when you want to cap how long an operation takes.

func fetchOrder(ctx context.Context, id string) (*Order, error) {
    // Give the database query at most 3 seconds
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel() // always call cancel to release resources

    row := db.QueryRowContext(ctx, "SELECT id, total FROM orders WHERE id = $1", id)

    var order Order
    if err := row.Scan(&order.ID, &order.Total); err != nil {
        return nil, fmt.Errorf("fetch order %s: %w", id, err)
    }
    return &order, nil
}

The defer cancel() call is critical. Without it, the context’s internal timer goroutine leaks until the parent context is cancelled.

context.WithDeadline

Use WithDeadline when you have an absolute wall-clock deadline, such as an SLA that says “respond by T”.

func HandleWithSLA(w http.ResponseWriter, r *http.Request) {
    deadline := time.Now().Add(5 * time.Second)
    ctx, cancel := context.WithDeadline(r.Context(), deadline)
    defer cancel()

    result, err := doExpensiveWork(ctx)
    if errors.Is(err, context.DeadlineExceeded) {
        http.Error(w, "request timed out", http.StatusGatewayTimeout)
        return
    }
    json.NewEncoder(w).Encode(result)
}

Nested Timeouts

When you nest timeouts, the shortest one wins. This is by design.

func processRequest(ctx context.Context) error {
    // Parent gives us 10 seconds total
    ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
    defer cancel()

    // Step 1 gets at most 3 seconds
    step1Ctx, step1Cancel := context.WithTimeout(ctx, 3*time.Second)
    defer step1Cancel()
    if err := stepOne(step1Ctx); err != nil {
        return fmt.Errorf("step 1: %w", err)
    }

    // Step 2 gets at most 5 seconds, but also bounded by parent's remaining time
    step2Ctx, step2Cancel := context.WithTimeout(ctx, 5*time.Second)
    defer step2Cancel()
    if err := stepTwo(step2Ctx); err != nil {
        return fmt.Errorf("step 2: %w", err)
    }

    return nil
}

Cancellation Propagation

context.WithCancel

Use WithCancel when you launch background work that should stop when the caller is done.

func streamResults(ctx context.Context, query string) (<-chan Result, error) {
    ctx, cancel := context.WithCancel(ctx)
    ch := make(chan Result)

    go func() {
        defer cancel()
        defer close(ch)

        rows, err := db.QueryContext(ctx, query)
        if err != nil {
            return
        }
        defer rows.Close()

        for rows.Next() {
            var r Result
            if err := rows.Scan(&r.ID, &r.Value); err != nil {
                return
            }
            select {
            case ch <- r:
            case <-ctx.Done():
                return
            }
        }
    }()

    return ch, nil
}

context.WithCancelCause (Go 1.20+)

When you cancel a context, you often want to explain why. WithCancelCause lets you attach an error.

func worker(ctx context.Context) error {
    ctx, cancel := context.WithCancelCause(ctx)
    defer cancel(nil)

    go func() {
        if err := healthCheck(); err != nil {
            cancel(fmt.Errorf("health check failed: %w", err))
        }
    }()

    select {
    case <-ctx.Done():
        return context.Cause(ctx) // returns the error passed to cancel
    case result := <-doWork(ctx):
        return processResult(result)
    }
}

Context Values: Use Sparingly

Context values are for request-scoped data that crosses API boundaries, not for passing function parameters.

Good Uses for Context Values

// Define unexported key types to avoid collisions
type contextKey string

const (
    requestIDKey contextKey = "requestID"
    userIDKey    contextKey = "userID"
)

// Provide typed accessors
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
}

Middleware That Sets Context Values

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))
    })
}

Anti-Patterns to Avoid

Do not use context values for:

  • Database connections (pass them as struct fields)
  • Logger instances (embed them in a struct or use slog’s handler)
  • Configuration (use dependency injection)

The rule of thumb: if removing the value from context would break your program’s correctness (not just its observability), it probably should not be in context.

Production Pattern: Graceful Shutdown

Combine context.WithCancel with os/signal for clean server shutdown.

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

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

    go func() {
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatalf("server error: %v", err)
        }
    }()

    <-ctx.Done()
    log.Println("shutting down gracefully...")

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

    if err := srv.Shutdown(shutdownCtx); err != nil {
        log.Fatalf("shutdown error: %v", err)
    }
    log.Println("server stopped")
}

Notice that Shutdown uses a fresh context.Background() with its own timeout, not the cancelled parent context.

Checking Context Before Expensive Work

In long-running loops, check the context regularly.

func processBatch(ctx context.Context, items []Item) error {
    for i, item := range items {
        select {
        case <-ctx.Done():
            return fmt.Errorf("cancelled after %d/%d items: %w", i, len(items), ctx.Err())
        default:
        }

        if err := processItem(ctx, item); err != nil {
            return fmt.Errorf("item %d: %w", i, err)
        }
    }
    return nil
}

The select with a default case is non-blocking. It checks whether the context has been cancelled without pausing.

Summary

  • Always pass context.Context as the first parameter.
  • Always defer cancel() after creating a derived context.
  • Use WithTimeout for duration-based limits and WithDeadline for absolute limits.
  • Use WithCancelCause to attach reasons to cancellations.
  • Keep context values minimal: request IDs, trace IDs, and auth metadata only.
  • Check ctx.Done() inside long-running loops.
  • Use signal.NotifyContext for graceful shutdown.

These patterns will keep your production services responsive, observable, and well-behaved under load.