Context

What is Context?

The context package provides a way to carry deadlines, cancellation signals, and request-scoped values across API boundaries and between goroutines. It’s the standard mechanism for controlling the lifecycle of operations in Go.

Every context has a parent, forming a tree. When a parent context is cancelled, all its children are cancelled too. This makes it possible to cancel an entire tree of operations with a single call.

  graph TD
    A[Background Context] --> B[Request Context]
    B --> C[Database Query]
    B --> D[HTTP Call]
    B --> E[Cache Lookup]

    A:::root
    B:::request
    C:::operation
    D:::operation
    E:::operation

    classDef root fill:#748796,stroke:#333,stroke-width:2px
    classDef request fill:#118098,stroke:#333,stroke-width:2px
    classDef operation fill:#077fed,stroke:#333,stroke-width:2px

When the request context is cancelled, all three operations (database query, HTTP call, cache lookup) are cancelled simultaneously.

Creating Contexts

Background and TODO

context.Background() returns an empty context. It’s the root of any context tree and is typically used in main(), initialization, and tests.

ctx := context.Background()

context.TODO() is also an empty context, but signals that you haven’t decided which context to use yet. Use it as a placeholder when refactoring code to add context support.

ctx := context.TODO() // Replace with proper context later.

WithCancel

context.WithCancel returns a copy of the parent context with a new Done channel. The returned cancel function must be called to release resources.

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel() // Always defer cancel to avoid leaks.

    go worker(ctx)

    time.Sleep(2 * time.Second)
    cancel() // Signal the worker to stop.
    time.Sleep(100 * time.Millisecond)
}

func worker(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("Worker stopped:", ctx.Err())
            return
        default:
            fmt.Println("Working...")
            time.Sleep(500 * time.Millisecond)
        }
    }
}

WithCancelCause (Go 1.20+)

context.WithCancelCause lets you provide a reason when cancelling, which can be retrieved later with context.Cause.

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

    go worker(ctx)

    time.Sleep(2 * time.Second)
    cancel(errors.New("shutting down for maintenance"))
}

func worker(ctx context.Context) {
    <-ctx.Done()
    fmt.Println("Stopped:", ctx.Err())           // context canceled
    fmt.Println("Cause:", context.Cause(ctx))    // shutting down for maintenance
}

This is useful for debugging and loggingβ€”you can distinguish between different reasons for cancellation.

WithTimeout and WithDeadline

context.WithTimeout creates a context that automatically cancels after a duration. context.WithDeadline does the same but with an absolute time.

func fetchData(ctx context.Context) error {
    // Create a context that times out after 3 seconds.
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()

    req, err := http.NewRequestWithContext(ctx, "GET", "https://api.example.com/data", nil)
    if err != nil {
        return fmt.Errorf("create request: %w", err)
    }

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        if errors.Is(ctx.Err(), context.DeadlineExceeded) {
            return fmt.Errorf("fetch data: request timed out: %w", err)
        }
        return fmt.Errorf("fetch data: %w", err)
    }
    defer resp.Body.Close()

    return nil
}

The difference between WithTimeout and WithDeadline:

// These are equivalent.
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
ctx, cancel := context.WithDeadline(ctx, time.Now().Add(5*time.Second))

Use WithTimeout when you think in terms of duration (“wait at most 5 seconds”). Use WithDeadline when you have a fixed point in time (“must complete by 2pm”).

WithoutCancel (Go 1.21+)

context.WithoutCancel returns a copy of the parent that is not cancelled when the parent is cancelled. This is useful when you need to perform cleanup operations that should complete even after the main request is cancelled.

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    // Main work that should respect cancellation.
    result, err := doWork(ctx)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    // Audit logging should complete even if client disconnects.
    go func() {
        auditCtx := context.WithoutCancel(ctx)
        auditCtx, cancel := context.WithTimeout(auditCtx, 5*time.Second)
        defer cancel()
        logAuditEvent(auditCtx, result)
    }()

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

AfterFunc (Go 1.21+)

context.AfterFunc registers a function to run after a context is cancelled. The function runs in its own goroutine.

func processWithCleanup(ctx context.Context, resource *Resource) error {
    // Register cleanup to run when context is cancelled.
    stop := context.AfterFunc(ctx, func() {
        resource.Cleanup()
    })
    defer stop() // Cancel the AfterFunc if we return normally.

    return resource.Process(ctx)
}

Listening for Cancellation

The Done() method returns a channel that closes when the context is cancelled. Use it in a select statement to respond to cancellation.

func longOperation(ctx context.Context) error {
    resultCh := make(chan string)

    go func() {
        // Simulate work.
        time.Sleep(5 * time.Second)
        resultCh <- "completed"
    }()

    select {
    case result := <-resultCh:
        fmt.Println("Operation", result)
        return nil
    case <-ctx.Done():
        return fmt.Errorf("long operation cancelled: %w", ctx.Err())
    }
}

Checking Context Errors

When a context is cancelled, ctx.Err() returns one of two sentinel errors:

  • context.Canceled β€” the context was explicitly cancelled via cancel()
  • context.DeadlineExceeded β€” the context’s deadline passed

Use errors.Is() to check for these errors:

if err := ctx.Err(); err != nil {
    if errors.Is(err, context.Canceled) {
        log.Println("Operation was cancelled")
    } else if errors.Is(err, context.DeadlineExceeded) {
        log.Println("Operation timed out")
    }
}

To get the underlying cause (if WithCancelCause was used):

if cause := context.Cause(ctx); cause != nil {
    log.Printf("Cancellation cause: %v", cause)
}

Passing Values

context.WithValue attaches a key-value pair to a context. This is useful for request-scoped data like request IDs, authentication tokens, or tracing information.

type contextKey string

const requestIDKey contextKey = "requestID"

func middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        requestID := uuid.New().String()
        ctx := context.WithValue(r.Context(), requestIDKey, requestID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func handler(w http.ResponseWriter, r *http.Request) {
    requestID, ok := r.Context().Value(requestIDKey).(string)
    if !ok {
        requestID = "unknown"
    }
    log.Printf("[%s] Handling request", requestID)
}

Value Best Practices

Use custom types for keys. This prevents collisions between packages that might use the same string key.

// Good: custom type prevents collisions.
type contextKey string

const userKey contextKey = "user"

// Bad: string keys can collide.
ctx = context.WithValue(ctx, "user", user) // Another package might use "user" too.

Don’t use context for optional parameters. Context values are for request-scoped data that transits process boundaries, not for passing function arguments.

// Bad: using context to pass configuration.
ctx = context.WithValue(ctx, "retryCount", 3)
doSomething(ctx)

// Good: use explicit parameters.
doSomething(ctx, RetryConfig{Count: 3})

Keep values immutable. Don’t store pointers to mutable data that you’ll modify later. Context values should be read-only.

Context Propagation Patterns

HTTP Handlers

HTTP requests come with a context attached. Use r.Context() and propagate it to downstream calls.

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    user, err := fetchUser(ctx, userID)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    orders, err := fetchOrders(ctx, user.ID)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    // Continue processing...
}

Database Operations

Most database drivers accept context for query cancellation.

func getUser(ctx context.Context, db *sql.DB, id int) (*User, error) {
    ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
    defer cancel()

    var user User
    err := db.QueryRowContext(ctx,
        "SELECT id, name, email FROM users WHERE id = $1", id,
    ).Scan(&user.ID, &user.Name, &user.Email)
    if err != nil {
        return nil, fmt.Errorf("get user %d: %w", id, err)
    }

    return &user, nil
}

Goroutines

When spawning goroutines, pass the context explicitly.

func processItems(ctx context.Context, items []Item) error {
    g, ctx := errgroup.WithContext(ctx)

    for _, item := range items {
        g.Go(func() error {
            return processItem(ctx, item)
        })
    }

    return g.Wait()
}

Note: In Go versions before 1.22, you needed to capture the loop variable with item := item before the goroutine. As of Go 1.22, loop variables are created fresh for each iteration, so this is no longer necessary.

Common Anti-Patterns

Storing context in structs

Context should flow through your program as a function parameter, not be stored in structs.

// Bad: context stored in struct.
type Service struct {
    ctx context.Context
    db  *sql.DB
}

// Good: context passed per-call.
type Service struct {
    db *sql.DB
}

func (s *Service) GetUser(ctx context.Context, id int) (*User, error) {
    return getUser(ctx, s.db, id)
}

Passing nil context

Never pass a nil context. Use context.TODO() if you’re unsure, or context.Background() for top-level calls.

// Bad: nil context can cause panics.
doSomething(nil)

// Good: explicit empty context.
doSomething(context.Background())

Ignoring cancellation

If you accept a context, respect it. Check ctx.Done() in long-running operations.

// Bad: ignores context.
func process(ctx context.Context, items []Item) {
    for _, item := range items {
        processItem(item) // What if context is cancelled?
    }
}

// Good: checks context.
func process(ctx context.Context, items []Item) error {
    for _, item := range items {
        select {
        case <-ctx.Done():
            return fmt.Errorf("process items: %w", ctx.Err())
        default:
            processItem(item)
        }
    }
    return nil
}

Summary

Function Purpose Added
Background() Root context for main, init, tests Go 1.7
TODO() Placeholder when context is unclear Go 1.7
WithCancel() Manual cancellation Go 1.7
WithCancelCause() Cancellation with reason Go 1.20
WithTimeout() Cancel after duration Go 1.7
WithDeadline() Cancel at specific time Go 1.7
WithValue() Attach request-scoped data Go 1.7
WithoutCancel() Detach from parent cancellation Go 1.21
AfterFunc() Run function on cancellation Go 1.21
Cause() Get cancellation reason Go 1.20

References