Skip to content

Context Package Intermediate

Introduction

The context package carries deadlines, cancellation signals, and request-scoped values across API boundaries and between goroutines. Every long-running or I/O-bound operation in Go should accept a context.Context as its first parameter. This is how you implement graceful shutdown, HTTP request timeouts, database query cancellation, and more.

Context is Go's answer to the question: "How do I tell downstream work to stop?"

Syntax & Usage

Root Contexts

Every context tree starts from a root. There are two:

ctx := context.Background() // default root — use in main(), init(), tests
ctx := context.TODO()       // placeholder when unsure which context to use

Background() and TODO() are identical in behavior — both return a non-nil, empty context that is never canceled. The difference is intent: TODO() signals to readers that the correct context needs to be wired in later.

context.WithCancel

Returns a derived context and a cancel function. Calling cancel() signals all goroutines watching this context to stop.

func monitorServer(ctx context.Context) {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel() // always call cancel to release resources

    go watchHealth(ctx)
    go watchMetrics(ctx)

    <-ctx.Done() // blocks until parent or this context is canceled
}

func watchHealth(ctx context.Context) {
    ticker := time.NewTicker(5 * time.Second)
    defer ticker.Stop()
    for {
        select {
        case <-ctx.Done():
            fmt.Println("health check stopped:", ctx.Err())
            return
        case <-ticker.C:
            checkHealth()
        }
    }
}

context.WithTimeout

Cancels automatically after a duration. Most common in HTTP handlers and database calls.

func fetchUser(ctx context.Context, id int) (*User, error) {
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()

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

context.WithDeadline

Like WithTimeout but takes an absolute time instead of a duration.

deadline := time.Now().Add(30 * time.Second)
ctx, cancel := context.WithDeadline(ctx, deadline)
defer cancel()

WithTimeout(ctx, 3*time.Second) is shorthand for WithDeadline(ctx, time.Now().Add(3*time.Second)).

context.WithValue

Attaches a key-value pair to the context. Used for request-scoped data like request IDs, authenticated users, and trace spans.

type contextKey string

const requestIDKey contextKey = "requestID"

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

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

Always use an unexported custom type for context keys

Using strings or other common types as keys risks collisions between packages. Define a private type: type contextKey string.

Context in HTTP Handlers

Every *http.Request carries a context. The server cancels it when the client disconnects.

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

    result := make(chan string, 1)
    go func() {
        result <- expensiveComputation()
    }()

    select {
    case <-ctx.Done():
        http.Error(w, "request canceled", http.StatusRequestTimeout)
        return
    case res := <-result:
        fmt.Fprint(w, res)
    }
}

Context in Database Operations

The database/sql package supports context throughout:

func transferFunds(ctx context.Context, db *sql.DB, from, to int, amount float64) error {
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()

    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return fmt.Errorf("beginning transaction: %w", err)
    }
    defer tx.Rollback()

    _, err = tx.ExecContext(ctx,
        "UPDATE accounts SET balance = balance - $1 WHERE id = $2", amount, from)
    if err != nil {
        return fmt.Errorf("debiting account %d: %w", from, err)
    }

    _, err = tx.ExecContext(ctx,
        "UPDATE accounts SET balance = balance + $1 WHERE id = $2", amount, to)
    if err != nil {
        return fmt.Errorf("crediting account %d: %w", to, err)
    }

    return tx.Commit()
}

Cancellation Propagation

Contexts form a tree. Canceling a parent cancels all children.

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

    go serviceA(ctx) // derives its own children
    go serviceB(ctx) // derives its own children

    time.Sleep(10 * time.Second)
    cancel() // cancels serviceA, serviceB, and ALL their descendants
}

Checking Context State

select {
case <-ctx.Done():
    fmt.Println(ctx.Err()) // context.Canceled or context.DeadlineExceeded
default:
    // context still active
}

// Or check deadline directly
if deadline, ok := ctx.Deadline(); ok {
    remaining := time.Until(deadline)
    fmt.Printf("time remaining: %v\n", remaining)
}

Quick Reference

Function Creates Auto-cancels? Use Case
context.Background() Root context Never main(), init, tests
context.TODO() Root context Never Placeholder during refactoring
context.WithCancel(parent) Cancelable child On cancel() call Manual cancellation
context.WithTimeout(parent, d) Deadline child After duration d HTTP calls, DB queries
context.WithDeadline(parent, t) Deadline child At time t Absolute deadline
context.WithValue(parent, k, v) Value-carrying child Inherits parent Request ID, auth user, trace
Method on context.Context Returns
Done() <-chan struct{} — closed when context is canceled
Err() nil, context.Canceled, or context.DeadlineExceeded
Deadline() Deadline time and whether one is set
Value(key) Value for key, or nil

Best Practices

  1. First parameter, named ctxfunc DoWork(ctx context.Context, ...) is the universal convention.
  2. Always call cancel() — use defer cancel() immediately after creation to prevent goroutine/resource leaks.
  3. Don't store context in structs — pass it explicitly through function parameters. Storing it breaks the request lifecycle model.
  4. Use WithTimeout for external calls — never let an HTTP request, database query, or RPC call run unbounded.
  5. Keep WithValue minimal — only for request-scoped data that crosses API boundaries (request ID, auth token, trace span). Never use it to pass function parameters.
  6. Pass the most specific context — if you have a timeout context derived from a request context, pass the timeout context to downstream calls.

Common Pitfalls

Forgetting to call cancel()

func handle(ctx context.Context) {
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    // BUG: cancel never called — goroutine and timer leak until parent cancels

    // FIX: always defer cancel
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()
}
Even if the operation completes before the timeout, calling cancel() releases the timer resources immediately.

Using context.WithValue as a function parameter bag

// WRONG — using context to pass business logic parameters
ctx = context.WithValue(ctx, "userID", 42)
ctx = context.WithValue(ctx, "limit", 100)
processUsers(ctx)

// RIGHT — pass parameters explicitly
processUsers(ctx, userID, limit)
Context values are untyped, invisible to function signatures, and impossible to discover without reading source code. Reserve them for cross-cutting concerns like tracing and auth.

Storing context in a struct field

// WRONG — context is tied to a request lifecycle, not an object lifecycle
type Server struct {
    ctx context.Context
}

// RIGHT — pass context per-call
func (s *Server) HandleRequest(ctx context.Context, req *Request) error { ... }
The context.Context docs explicitly state: "Do not store Contexts inside a struct type."

Ignoring ctx.Done() in long-running loops

// BUG: if context is canceled, this loop runs forever
func process(ctx context.Context, items []Item) {
    for _, item := range items {
        handle(item) // never checks ctx
    }
}

// FIX: check context in the loop
func process(ctx context.Context, items []Item) error {
    for _, item := range items {
        select {
        case <-ctx.Done():
            return ctx.Err()
        default:
        }
        handle(item)
    }
    return nil
}

Performance Considerations

  • Context creation is cheapWithCancel, WithTimeout, and WithValue allocate a small struct. Don't hesitate to create derived contexts.
  • Value() lookup is O(n) in the depth of the context chain. Keep the chain shallow (typically 3–5 levels). Don't use it as a key-value store with dozens of entries.
  • Timer resources: WithTimeout and WithDeadline create an internal timer. Calling cancel() stops the timer and frees memory — this is why defer cancel() matters even on the happy path.
  • Channel close is cheap: Done() returns a channel that's closed on cancellation. The select on a closed channel returns immediately — no performance concern for checking frequently.

Interview Tips

Interview Tip

"What is context used for in Go?" Context carries cancellation signals, deadlines, and request-scoped values across API boundaries. It's how you implement timeouts on HTTP calls, cancel database queries when a user disconnects, and propagate request IDs for tracing. It's the first parameter of any function that does I/O or long-running work.

Interview Tip

"What's the difference between WithTimeout and WithDeadline?" WithTimeout takes a duration (relative), WithDeadline takes an absolute time. WithTimeout(ctx, 5*time.Second) is literally shorthand for WithDeadline(ctx, time.Now().Add(5*time.Second)). Use WithTimeout in most cases; use WithDeadline when you have a fixed wall-clock deadline.

Interview Tip

"Why should you always defer cancel()?" Two reasons: (1) it releases internal timer resources immediately instead of waiting for the timeout to expire, and (2) it prevents goroutine leaks — any goroutine waiting on ctx.Done() will be unblocked. Even if the operation finishes before the deadline, calling cancel() is necessary for cleanup.

Interview Tip

"When should you use context.TODO() vs context.Background()?" Both return identical empty contexts. Background() is for top-level calls — main(), init(), test setup. TODO() signals intent: "I know I need a real context here, but I haven't plumbed it through yet." It's a refactoring breadcrumb.

Key Takeaways

  • Context carries cancellation, deadlines, and request-scoped values through the call stack.
  • Always pass context.Context as the first parameter, named ctx.
  • Always defer cancel() immediately after creating a derived context.
  • Use WithTimeout for external calls (HTTP, DB, RPC) — never let them run unbounded.
  • Use WithValue sparingly — only for cross-cutting concerns, never for function parameters.
  • Don't store context.Context in struct fields — pass it per-call.
  • Canceling a parent context cancels all its children recursively.