• English
  • Go context in Production: Lifetime, Cancellation, and API Boundaries

    The context package defines the lifetime contract for concurrent work in Go.

    It propagates:

    • cancellation,
    • deadlines,
    • request-scoped metadata,

    across function calls, goroutines, and service boundaries.

    It does not provide:

    • forced goroutine termination,
    • synchronization completion,
    • dependency injection,
    • configuration management,
    • general-purpose request state.

    A production context design must preserve the following invariants.

    Core Rules

    1. Pass context.Context explicitly.
    2. Make ctx the first parameter of context-aware operations.
    3. Do not store request contexts in long-lived structs.
    4. Do not pass nil as a context.
    5. Propagate the incoming context unless a deliberate lifetime boundary is required.
    6. Derive child contexts; do not replace the caller's context with context.Background().
    7. Call every returned CancelFunc when the derived operation is no longer needed.
    8. Cancellation is a signal, not proof of completion.
    9. Every cancellable goroutine must have an owner and an observable cancellation path.
    10. Every potentially indefinite blocking operation must have an escape path.
    11. Treat deadlines as a finite execution budget.
    12. Use context.Cause when the reason for cancellation carries operational meaning.
    13. Use Context.Value only for request-scoped data crossing API or process boundaries.
    14. Do not use context values for configuration, dependencies, business arguments, or optional parameters.
    15. Use WithoutCancel only when intentionally creating a new lifetime boundary.
    16. Use explicit synchronization when cancellation must be followed by completion.

    The standard library defines Context as carrying deadlines, cancellation signals, and request-scoped values across API boundaries. Contexts are safe for concurrent use, and derived contexts form a cancellation tree.

    1. The Lifetime Contract

    1.1 Context Defines Operation Lifetime

    A context-aware function accepts the caller's lifetime:

    func Fetch(ctx context.Context, id int64) (*User, error)

    The caller controls whether the operation remains useful.

    The implementation must preserve that contract:

    request
    
    
    handler
    
    
    service
    
    
    repository
    
    
    database / RPC

    The context should normally flow through the entire chain.

    Replacing it breaks the lifetime contract:

    func Fetch(ctx context.Context, id int64) error {
        return client.Call(context.Background())
    }

    The resulting operation no longer observes:

    • request cancellation,
    • request deadline,
    • shutdown cancellation,
    • request-scoped metadata.

    The production rule is simple:

    Incoming context
    
          ├── preserve cancellation
          ├── preserve deadline
          └── preserve values

    unless the operation explicitly establishes a different lifetime.


    1.2 Context Is Not a Goroutine Killer

    Cancellation is cooperative.

    ctx, cancel := context.WithCancel(parent)
    cancel()

    does not terminate a goroutine.

    It closes the context's Done channel. The goroutine must observe it:

    func worker(ctx context.Context) error {
        for {
            select {
            case <-ctx.Done():
                return ctx.Err()
    
            case item := <-jobs:
                process(item)
            }
        }
    }

    A function that accepts a context but performs an uninterruptible operation is not necessarily context-aware:

    func bad(ctx context.Context) error {
        time.Sleep(10 * time.Second)
        return nil
    }

    Cancellation cannot interrupt the Sleep.

    The operation must either use a context-aware API or provide its own cancellation checkpoint.


    1.3 Cancellation and Completion Are Different Events

    Cancellation means:

    stop requested

    It does not mean:

    worker has stopped

    This distinction is fundamental when combining context with sync.

    ctx, cancel := context.WithCancel(parent)
    
    var wg sync.WaitGroup
    
    wg.Add(1)
    go func() {
        defer wg.Done()
        worker(ctx)
    }()
    
    cancel()
    wg.Wait()

    The responsibilities are separate:

    context.Context
        → cancellation signal
    
    sync.WaitGroup
        → completion tracking

    Do not close or destroy shared resources merely because cancel() has returned. Wait for workers when resource ownership requires completed shutdown.


    1.4 Context Scope Must Match Work Scope

    For every context, define:

    What work does cancellation stop?
    Who owns that work?
    When does that work cease to be useful?

    A request context is appropriate for request-specific work:

    HTTP request
    
       ├── authentication
       ├── database query
       ├── cache lookup
       └── downstream RPC

    It is usually inappropriate for durable work:

    HTTP request
    
       └── durable background job

    If the job must survive request cancellation, its lifetime must be transferred explicitly to a job queue, worker, or other owner.


    1.5 Context Roots

    context.Background() and context.TODO() are root contexts.

    Use Background for application-level roots:

    func main() {
        ctx := context.Background()
        run(ctx)
    }

    Use TODO when the correct propagation path is not yet established:

    func legacyAdapter() {
        ctx := context.TODO()
        // Context propagation still needs to be wired through.
    }

    TODO should not become a permanent substitute for context design.

    Production request paths should normally derive from an existing request context rather than creating new roots.


    2. Cancellation, Deadlines, and Causes

    2.1 WithCancel: Explicit Cancellation

    Use WithCancel when the child operation ends because of an explicit event:

    ctx, cancel := context.WithCancel(parent)
    defer cancel()
    
    return run(ctx)

    The child inherits the parent's lifetime and can additionally be canceled by its owner.

    The cancellation tree is:

    parent
    
      ├── child A
      ├── child B
      └── child C

    Canceling the parent cancels all descendants.

    Canceling a child does not cancel its parent.

    This establishes an ownership direction:

    owner
    
      └── owned work

    The owner may terminate the work without terminating unrelated siblings.


    2.2 CancelFunc Is Resource Management

    Always retain and invoke the cancellation function when the derived operation is complete:

    ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
    defer cancel()

    Calling cancel does more than communicate cancellation.

    For cancelable contexts, it also removes the child from its parent's cancellation tree and releases associated resources. For timer contexts, cancellation stops the timer. The current implementation explicitly removes the child from the parent and stops its time.Timer.

    Therefore:

    ctx, cancel := context.WithTimeout(parent, timeout)
    defer cancel()

    is a resource-management pattern, not merely a stylistic convention.


    2.3 WithTimeout: Bound the Operation

    ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
    defer cancel()
    
    return callBackend(ctx)

    The effective deadline is the earlier of:

    parent deadline
    new timeout deadline

    A child cannot extend the parent's deadline.

    Therefore:

    parent deadline = T1
    child deadline  = T2
    
    effective = min(T1, T2)

    This produces a monotonic deadline invariant:

    moving down the context tree
    
    deadline stays the same
    or becomes earlier
    
    never becomes later

    2.4 Deadlines Are Execution Budgets

    Suppose a request has:

    1 second remaining

    and sequentially performs:

    authentication
    database query
    RPC

    Each operation should consume the same request budget rather than receiving an independent one-second timeout.

    The desired model is:

    request deadline
    
           ├── authentication
    
           ├── database
    
           └── RPC

    not:

    authentication → 1s
    database       → 1s
    RPC            → 1s

    The propagated deadline allows each layer to determine how much time remains.

    Before starting expensive work:

    deadline, ok := ctx.Deadline()
    if ok && time.Until(deadline) <= 20*time.Millisecond {
        return context.DeadlineExceeded
    }

    This prevents operations from consuming resources after their result is unlikely to be useful.


    2.5 WithDeadline vs WithTimeout

    Use WithTimeout when the policy is expressed as a duration:

    context.WithTimeout(ctx, 100*time.Millisecond)

    Use WithDeadline when an absolute deadline already exists:

    context.WithDeadline(ctx, deadline)

    Distributed systems commonly benefit from absolute deadlines because downstream components can calculate the remaining budget from the original deadline.


    2.6 Timer Contexts Have Real Runtime Cost

    WithTimeout is not equivalent to storing a timestamp in a struct.

    A deadline context uses a timer-backed implementation. In the current standard library, timerCtx embeds cancelCtx and stores a *time.Timer; the timer is installed with time.AfterFunc and stopped when the context is canceled.

    The relevant cost model is therefore:

    WithTimeout / WithDeadline
    
        ├── derived context state
        ├── cancellation-tree bookkeeping
        └── timer state

    For ordinary request processing this cost is usually appropriate.

    For extremely high-rate systems such as:

    • reverse proxies,
    • connection schedulers,
    • large scraper pools,
    • high-frequency RPC fan-out,

    creating millions of short-lived timeout contexts can become a measurable allocation and timer-management cost.

    The optimization rule is not:

    avoid context.WithTimeout

    It is:

    do not create redundant timers when an existing deadline already provides
    the required bound.

    For example, if the parent already has an earlier deadline:

    ctx, cancel := context.WithTimeout(parent, 5*time.Second)
    defer cancel()

    does not create a later effective deadline. The standard library can reuse the parent cancellation path when the existing deadline is already sooner.


    2.7 Err: Control-Flow Classification

    After cancellation:

    err := ctx.Err()

    returns:

    context.Canceled

    or:

    context.DeadlineExceeded

    Use these values for control flow:

    if errors.Is(err, context.Canceled) {
        return err
    }

    Do not convert cancellation into an unrelated error:

    return errors.New("operation failed")

    because that destroys the cancellation contract.


    2.8 Cancellation Causes

    Use WithCancelCause when cancellation has a meaningful operational reason:

    ctx, cancel := context.WithCancelCause(parent)
    defer cancel(nil)
    
    cancel(ErrReplicaLost)

    The two APIs provide different information:

    ctx.Err()
        → cancellation category
    
    context.Cause(ctx)
        → cancellation reason

    For example:

    var ErrReplicaLost = errors.New("replica lost")
    
    ctx, cancel := context.WithCancelCause(parent)
    cancel(ErrReplicaLost)
    
    fmt.Println(ctx.Err())
    // context canceled
    
    fmt.Println(context.Cause(ctx))
    // replica lost

    The cancellation cause is established by the first cancellation affecting that context. If the parent is canceled first, the parent's cause propagates to the child; if the child is canceled first, the child retains its own cause.


    2.9 WithTimeoutCause and WithDeadlineCause

    Use a cause-aware timeout when timer expiration itself has semantic meaning:

    ctx, cancel := context.WithTimeoutCause(
        parent,
        100*time.Millisecond,
        ErrBackendBudgetExceeded,
    )
    defer cancel()

    When the timer expires:

    ctx.Err() == context.DeadlineExceeded

    while:

    context.Cause(ctx) == ErrBackendBudgetExceeded

    The returned CancelFunc does not set the configured cause; the cause is associated with deadline expiration.

    This is useful for observability and policy decisions without changing the standard cancellation classification.


    3. Context and Concurrency Boundaries

    3.1 Context Does Not Replace sync

    The responsibilities are distinct:

    MechanismResponsibility
    context.Contextcancellation and lifetime
    sync.WaitGroupcompletion tracking
    sync.Mutexmutual exclusion
    sync.Condcondition synchronization
    channelcommunication and synchronization
    errorsfailure semantics

    A context does not guarantee that workers have stopped.

    A WaitGroup does not communicate request cancellation.

    Production concurrency often needs both.


    3.2 Worker Lifetime

    A worker should expose cancellation explicitly:

    func RunWorker(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 {
                    if ctx.Err() != nil {
                        return ctx.Err()
                    }
                    return err
                }
            }
        }
    }

    The worker has explicit termination conditions:

    context canceled
            OR
    jobs closed
            OR
    processing failed

    There is no global shutdown flag.


    3.3 Every Blocking Point Needs an Escape Path

    This is incomplete:

    func worker(ctx context.Context) {
        for {
            item := <-jobs
            process(item)
        }
    }

    The receive can block indefinitely.

    Use:

    select {
    case <-ctx.Done():
        return
    
    case item := <-jobs:
        process(item)
    }

    The same rule applies to:

    • channel receives,
    • channel sends,
    • condition waits,
    • network operations,
    • database operations,
    • timers,
    • RPCs,
    • retry loops.

    A function is not context-safe merely because its signature contains context.Context.

    Cancellation must reach the actual blocking operation.


    3.4 Cancellation in CPU-Bound Work

    CPU-bound work has no external blocking primitive to interrupt.

    It must introduce cancellation checkpoints:

    func Compute(ctx context.Context) error {
        for i := 0; i < 1_000_000; i++ {
            if i%1000 == 0 {
                select {
                case <-ctx.Done():
                    return ctx.Err()
                default:
                }
            }
    
            computeChunk(i)
        }
    
        return nil
    }

    The checkpoint frequency is an engineering trade-off:

    more checks
        → faster cancellation
        → more control overhead
    
    fewer checks
        → lower overhead
        → slower cancellation

    The correct frequency depends on the amount of work performed between checkpoints.


    3.5 Fan-Out Cancellation

    For redundant work:

    request
     ├── replica A
     ├── replica B
     └── replica C

    create a child cancellation boundary:

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

    When one result makes the others unnecessary:

    result := <-results
    cancel()
    return result

    The cancellation is an optimization as well as a correctness mechanism.

    It stops unnecessary:

    • CPU work,
    • network traffic,
    • database queries,
    • memory usage,
    • downstream load.

    3.6 Cancellation and Retries

    A retry loop must terminate when the caller no longer wants the operation:

    for attempt := 0; attempt < 3; attempt++ {
        err := call(ctx)
        if err == nil {
            return nil
        }
    
        if ctx.Err() != nil {
            return ctx.Err()
        }
    
        if !retryable(err) {
            return err
        }
    }

    Do not blindly retry:

    context.Canceled
    context.DeadlineExceeded

    The parent deadline defines the upper budget for the retry loop.

    A retry policy must never silently create a larger budget than the caller provided.


    3.7 AfterFunc: Cancellation at Non-Context-Aware Boundaries

    context.AfterFunc connects cancellation to APIs that do not directly accept a context.

    stop := context.AfterFunc(ctx, func() {
        cleanup()
    })
    defer stop()

    The callback runs in its own goroutine after cancellation.

    stop() returns:

    true
        → callback was prevented from starting
    
    false
        → callback has already started or was already stopped

    stop() does not wait for an already-running callback to finish. Explicit synchronization is required when completion matters.


    3.8 AfterFunc and sync.Cond

    sync.Cond has no native context parameter.

    AfterFunc can bridge the cancellation boundary:

    func waitOnCond(
        ctx context.Context,
        cond *sync.Cond,
        conditionMet func() bool,
    ) error {
        stop := context.AfterFunc(ctx, func() {
            cond.L.Lock()
            defer cond.L.Unlock()
    
            cond.Broadcast()
        })
        defer stop()
    
        cond.L.Lock()
        defer cond.L.Unlock()
    
        for !conditionMet() {
            cond.Wait()
    
            if err := ctx.Err(); err != nil {
                return err
            }
        }
    
        return nil
    }

    The callback must coordinate with the condition variable's lock to avoid a missed wake-up.

    The standard library's own context examples use this pattern and explicitly address the synchronization requirement.

    This is a good example of the division of responsibilities:

    context
        → cancellation signal
    
    AfterFunc
        → bridge cancellation into Cond
    
    sync.Cond
        → condition synchronization

    3.9 AfterFunc and Network Operations

    AfterFunc can also interrupt an API that lacks direct context support.

    For example, a blocked net.Conn.Read can be interrupted by changing its read deadline:

    stop := context.AfterFunc(ctx, func() {
        conn.SetReadDeadline(time.Now())
    })
    defer stop()
    
    n, err := conn.Read(buf)

    If the callback has started, synchronization may be required before restoring the connection state.

    The standard library's own example uses a completion channel for exactly this reason.

    This is the correct mental model:

    context cancellation
    
    
    AfterFunc callback
    
    
    underlying API interrupt
    
    
    blocked operation returns

    3.10 AfterFunc Is Not a Destructor

    Do not treat:

    stop()

    as:

    cleanup completed

    If completion matters:

    done := make(chan struct{})
    
    stop := context.AfterFunc(ctx, func() {
        defer close(done)
        cleanup()
    })
    
    if !stop() {
        <-done
    }

    The exact synchronization mechanism depends on the resource.

    The invariant remains:

    registration state
    
    execution completion

    4. Context Values and Lifetime Detachment

    4.1 Value Is Request Metadata

    Context.Value should carry request-scoped data that crosses API boundaries.

    Good candidates:

    trace information
    request ID
    authentication metadata
    tenant identity
    locale
    security credentials

    Bad candidates:

    database connection
    logger instance
    cache
    configuration
    retry policy
    service instance
    business arguments
    large mutable state

    The official package documentation explicitly limits context values to request-scoped data that transits processes and APIs rather than optional parameters.


    4.2 Explicit Arguments vs Context Values

    Use an explicit argument when the value defines the operation:

    func DeleteUser(ctx context.Context, userID int64) error

    Do not hide it:

    ctx = context.WithValue(ctx, userIDKey{}, userID)
    
    DeleteUser(ctx)

    Use context when metadata accompanies the operation:

    request
     ├── request ID
     ├── trace metadata
     └── authentication metadata

    Use explicit parameters for business inputs:

    user ID
    order ID
    query
    filter
    pagination
    business options

    This keeps API dependencies visible.


    4.3 Use Private Key Types

    Do not use strings:

    context.WithValue(ctx, "requestID", id)

    Use a package-private key type:

    type requestIDKey struct{}
    
    func withRequestID(
        ctx context.Context,
        id string,
    ) context.Context {
        return context.WithValue(ctx, requestIDKey{}, id)
    }

    Then provide typed accessors:

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

    This prevents collisions between unrelated packages.


    4.4 Stored Values Must Be Concurrency-Safe

    Contexts are safe for concurrent use.

    That guarantee does not make the objects stored in them safe.

    This is unsafe if accessed concurrently:

    state := map[string]string{}
    
    ctx = context.WithValue(ctx, stateKey{}, state)

    The map still requires its own synchronization.

    Context provides:

    safe concurrent context access

    not:

    safe concurrent access to arbitrary values

    4.5 Context Is Not Dependency Injection

    Do not build:

    ctx = context.WithValue(ctx, dbKey{}, db)
    ctx = context.WithValue(ctx, loggerKey{}, logger)
    ctx = context.WithValue(ctx, cacheKey{}, cache)

    and expose:

    func Process(ctx context.Context) error

    This hides dependencies.

    Prefer:

    type Service struct {
        db     *sql.DB
        logger *slog.Logger
        cache  Cache
    }
    
    func (s *Service) Process(ctx context.Context) error {
        // ...
    }

    The service owns its dependencies.

    The caller owns the operation lifetime.


    4.6 WithoutCancel: Explicit Lifetime Detachment

    WithoutCancel creates a context that preserves values but is not canceled when the parent is canceled.

    It has:

    Done()     → nil
    Err()      → nil
    Deadline() → no deadline
    Value()    → inherited values

    This makes it fundamentally different from Background().

    The relationship is:

    request context
    
    
    WithoutCancel
    
           ├── values preserved
           └── cancellation detached

    The function is useful when metadata must survive a request lifetime boundary.


    4.7 WithoutCancel and Asynchronous Handoff

    A concrete use case is asynchronous persistence of request metadata.

    For example:

    HTTP request
    
        ├── tracing/request metadata
    
        └── enqueue audit record
    
    
           asynchronous writer

    The asynchronous writer should not inherit the request's cancellation if the record must still be persisted after the HTTP handler returns.

    A possible boundary is:

    handoffCtx := context.WithoutCancel(ctx)
    
    writeCtx, cancel := context.WithTimeout(
        handoffCtx,
        5*time.Second,
    )
    defer cancel()
    
    queue.Write(writeCtx, record)

    The resulting semantics are:

    request cancellation
        → does not cancel the handoff
    
    request values
        → remain available
    
    new operation
        → receives its own explicit deadline

    This is appropriate for metadata such as:

    • tracing identifiers,
    • request identifiers,
    • tenant metadata,
    • audit context,

    when the asynchronous operation legitimately needs those values.

    It is not appropriate merely because a developer wants to ignore cancellation.


    4.8 WithoutCancel Must Not Create Immortal Work

    This is dangerous:

    go func() {
        ctx := context.WithoutCancel(requestCtx)
        writeToDatabase(ctx)
    }()

    The derived context has no deadline and no cancellation signal.

    If writeToDatabase blocks indefinitely, the goroutine can remain indefinitely.

    The production pattern is:

    handoffCtx := context.WithoutCancel(requestCtx)
    
    ctx, cancel := context.WithTimeout(
        handoffCtx,
        5*time.Second,
    )
    defer cancel()
    
    go writeToDatabase(ctx)

    But even this does not solve ownership by itself.

    For durable work, a queue or worker system is usually the correct owner.

    WithoutCancel should therefore be treated as a lifetime transition primitive, not a background-work primitive.


    4.9 Context Values and Tracing

    Context values are particularly appropriate for propagation metadata:

    HTTP request
    
    
    context
    
        ├── trace metadata
        ├── request ID
        └── authentication metadata
    
    
    service
    
    
    database / RPC

    The context carries metadata.

    The tracing system owns tracing behavior.

    The logger owns logging behavior.

    The service owns its dependencies.

    This keeps the context's responsibility bounded.


    5. Production API Boundaries

    5.1 Context Must Be Explicit

    Preferred:

    func Fetch(
        ctx context.Context,
        id string,
    ) (*Item, error)

    Not:

    func Fetch(
        id string,
        ctx context.Context,
    ) (*Item, error)

    and not:

    type Service struct {
        ctx context.Context
    }

    The conventional first parameter makes context propagation visible to:

    • reviewers,
    • callers,
    • static analysis,
    • API documentation.

    The official package guidance explicitly recommends passing context as the first parameter and not storing it in a struct.


    5.2 Never Pass nil

    Do not make context optional:

    func Fetch(ctx context.Context) error {
        if ctx == nil {
            ctx = context.Background()
        }
    
        // ...
    }

    This silently changes the caller's lifetime semantics.

    If the correct context is unknown during a migration:

    ctx := context.TODO()

    This makes the missing lifetime explicit.

    The standard library guidance explicitly states that callers should not pass nil contexts.


    5.3 Do Not Store Request Contexts in Long-Lived Objects

    Bad:

    type Repository struct {
        ctx context.Context
    }

    A repository may live for the lifetime of the process.

    A request context may live for milliseconds.

    Their scopes do not match.

    Prefer:

    type Repository struct {
        db *sql.DB
    }
    
    func (r *Repository) Find(
        ctx context.Context,
        id int64,
    ) (*User, error) {
        // ...
    }

    The repository owns:

    database dependency

    The caller owns:

    operation lifetime

    5.4 Constructors Usually Do Not Need Context

    Do not add context merely because a type will later perform context-aware work.

    Prefer:

    func NewClient(
        transport http.RoundTripper,
    ) *Client

    and:

    func (c *Client) Fetch(
        ctx context.Context,
        id string,
    ) error

    Use context in a constructor only when construction itself is a caller-controlled operation.

    For example, if construction performs network discovery or a blocking initialization operation, a context can be appropriate.

    The rule is:

    context belongs to the operation being bounded

    not automatically to the object being constructed.


    5.5 HTTP Request Propagation

    An HTTP handler should normally begin with:

    func handler(
        w http.ResponseWriter,
        r *http.Request,
    ) {
        ctx := r.Context()
    
        result, err := service.Fetch(ctx)
        if err != nil {
            // ...
            return
        }
    
        // ...
    }

    The context should continue through service and repository layers:

    r.Context()
    
    
    handler
    
    
    service
    
    
    repository
    
    
    database / HTTP client

    This permits client cancellation and request deadlines to propagate to downstream work.


    5.6 Database Operations

    Database APIs that accept context should receive the propagated context:

    func (r *Repository) FindUser(
        ctx context.Context,
        id int64,
    ) (*User, error) {
        row := r.db.QueryRowContext(
            ctx,
            `SELECT id, name FROM users WHERE id = ?`,
            id,
        )
    
        var user User
    
        if err := row.Scan(
            &user.ID,
            &user.Name,
        ); err != nil {
            return nil, err
        }
    
        return &user, nil
    }

    The context must reach the operation that can block.

    Passing it through five functions and then calling a context-free API at the sixth layer provides no cancellation guarantee.


    5.7 HTTP Client Operations

    Use the request context directly:

    req, err := http.NewRequestWithContext(
        ctx,
        http.MethodGet,
        url,
        nil,
    )
    if err != nil {
        return err
    }
    
    resp, err := client.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    The downstream request now participates in the caller's:

    • cancellation,
    • deadline,
    • request lifetime.

    This is the correct propagation chain:

    incoming request
    
    
    outbound request

    rather than creating a separate unrelated timeout system.


    5.8 Transactions

    Context can define the transaction operation's lifetime:

    func Transfer(
        ctx context.Context,
        db *sql.DB,
    ) error {
        tx, err := db.BeginTx(ctx, nil)
        if err != nil {
            return err
        }
    
        // transaction work
    
        return tx.Commit()
    }

    Context does not replace transaction semantics.

    The transaction still requires:

    begin
    
    work
    
    commit / rollback

    Context supplies the cancellation boundary.

    The transaction API owns transaction state.


    6. Anti-Patterns, Runtime Behavior, and Testing

    6.1 Anti-Pattern: Dropping the Context

    Incorrect

    func Fetch(ctx context.Context) error {
        return client.Call(context.Background())
    }

    Production Standard

    func Fetch(ctx context.Context) error {
        return client.Call(ctx)
    }

    Invariant:

    A downstream operation must not silently outlive its caller.

    6.2 Anti-Pattern: Replacing the Context

    Incorrect

    ctx = context.Background()

    Production Standard

    ctx, cancel := context.WithTimeout(ctx, timeout)
    defer cancel()

    Invariant:

    Derive a stricter lifetime; do not discard the existing one.

    6.3 Anti-Pattern: Context in a Struct

    Incorrect

    type Service struct {
        ctx context.Context
    }

    Production Standard

    type Service struct {
        db *sql.DB
    }
    
    func (s *Service) Fetch(
        ctx context.Context,
    ) error {
        // ...
    }

    Invariant:

    Long-lived dependencies and short-lived operation lifetimes remain separate.

    6.4 Anti-Pattern: Context as a Dependency Container

    Incorrect

    ctx = context.WithValue(ctx, dbKey{}, db)
    ctx = context.WithValue(ctx, loggerKey{}, logger)
    ctx = context.WithValue(ctx, configKey{}, config)

    Production Standard

    type Service struct {
        db     *sql.DB
        logger *slog.Logger
        config Config
    }

    Invariant:

    Context carries operation metadata, not application architecture.

    6.5 Anti-Pattern: Context as Business Arguments

    Incorrect

    ctx = context.WithValue(ctx, userIDKey{}, userID)
    
    return DeleteUser(ctx)

    Production Standard

    return DeleteUser(ctx, userID)

    Invariant:

    Business inputs remain visible in the function signature.

    6.6 Anti-Pattern: Unbounded Worker

    Incorrect

    func worker(ctx context.Context) {
        for {
            job := <-jobs
            process(job)
        }
    }

    Production Standard

    func worker(ctx context.Context) {
        for {
            select {
            case <-ctx.Done():
                return
    
            case job := <-jobs:
                process(ctx, job)
            }
        }
    }

    Invariant:

    Every long-lived worker must have an explicit cancellation path.

    6.7 Anti-Pattern: Forgotten CancelFunc

    Incorrect

    ctx, _ = context.WithTimeout(ctx, time.Second)

    Production Standard

    ctx, cancel := context.WithTimeout(ctx, time.Second)
    defer cancel()

    Invariant:

    The owner of a derived context owns its cancellation function.

    The standard documentation explicitly notes that failing to call the cancellation function can retain the child and descendants until the parent is canceled. go vet checks relevant cancellation paths.


    6.8 Anti-Pattern: Assuming Cancellation Means Cleanup Finished

    Incorrect

    cancel()
    close(sharedResource)

    Production Standard

    cancel()
    wg.Wait()
    close(sharedResource)

    when workers may still access the resource.

    Invariant:

    cancellation request ≠ worker completion

    6.9 Anti-Pattern: Arbitrary Timeout at Every Layer

    Incorrect

    handler       → 30s
    service       → 30s
    repository    → 30s
    client        → 30s

    Production Standard

    request deadline
    
           ├── service
           ├── database → tighter budget if required
           └── RPC      → tighter budget if required

    Invariant:

    A child timeout must represent a real resource boundary.

    Do not create timeout contexts merely because the API allows it.


    6.10 Anti-Pattern: Retrying After Cancellation

    Incorrect

    for i := 0; i < 5; i++ {
        if err := call(ctx); err != nil {
            continue
        }
        return nil
    }

    Production Standard

    for i := 0; i < 5; i++ {
        if err := call(ctx); err != nil {
            if ctx.Err() != nil {
                return ctx.Err()
            }
    
            if !retryable(err) {
                return err
            }
    
            continue
        }
    
        return nil
    }

    Invariant:

    A canceled operation does not become useful merely because another retry is available.

    6.11 Anti-Pattern: Detached Work Without a New Deadline

    Incorrect

    go func() {
        ctx := context.WithoutCancel(requestCtx)
        write(ctx)
    }()

    Production Standard

    handoff := context.WithoutCancel(requestCtx)
    
    ctx, cancel := context.WithTimeout(
        handoff,
        5*time.Second,
    )
    defer cancel()
    
    go write(ctx)

    For durable work, prefer transferring ownership to a queue or worker system.

    Invariant:

    Detached cancellation requires an explicit replacement lifetime.

    6.12 Runtime Cost Model

    The principal costs of context operations are structural rather than magical:

    WithCancel
        → derived context
        → cancellation relationship
    
    WithValue
        → derived value context
        → value lookup chain
    
    WithTimeout / WithDeadline
        → derived cancellation context
        → deadline state
        → timer state
        → cancellation relationship

    The current implementation's timerCtx embeds cancelCtx, stores a *time.Timer, and stops the timer during cancellation.

    For ordinary request workloads, these costs are justified by the lifetime guarantees.

    For high-volume infrastructure, optimize only after measuring.

    Useful targets for measurement include:

    contexts created per request
    timers created per request
    short-lived timeout contexts
    allocation rate
    GC pressure
    timer management overhead

    Do not replace correct context propagation with ad hoc global timers merely to avoid a small allocation without evidence.


    6.13 Go 1.27 Baseline

    Go 1.27 was released on August 19, 2026. The release includes broad language, runtime, toolchain, and standard-library changes, but it does not introduce a new major context API surface. The current context API remains centered on:

    WithCancel
    WithDeadline
    WithTimeout
    WithValue
    
    WithCancelCause
    WithDeadlineCause
    WithTimeoutCause
    Cause
    
    WithoutCancel
    AfterFunc

    WithoutCancel, WithDeadlineCause, WithTimeoutCause, and AfterFunc were introduced in Go 1.21; Cause and WithCancelCause were introduced in Go 1.20.

    Therefore, Go 1.27 alignment means:

    use current context semantics
    +
    avoid obsolete pre-1.20 cancellation assumptions
    +
    account for current runtime implementation
    +
    use current testing/concurrency facilities

    rather than inventing a Go 1.27-specific context API.


    6.14 Testing Cancellation

    A cancellation test must verify worker termination, not merely context state.

    Incorrect

    cancel()
    time.Sleep(10 * time.Millisecond)
    
    if !stopped {
        t.Fatal("worker still running")
    }

    The test synchronizes through a timing guess.

    Production Standard

    ctx, cancel := context.WithCancel(context.Background())
    
    done := make(chan struct{})
    
    go func() {
        defer close(done)
        worker(ctx)
    }()
    
    cancel()
    
    select {
    case <-done:
    case <-time.After(time.Second):
        t.Fatal("worker did not stop")
    }

    The synchronization event is:

    worker exited

    not:

    10 ms elapsed

    6.15 Testing Cancellation Causes

    Cause-aware cancellation should be tested explicitly:

    var ErrReplicaLost = errors.New("replica lost")
    
    ctx, cancel := context.WithCancelCause(
        context.Background(),
    )
    
    cancel(ErrReplicaLost)
    
    if !errors.Is(context.Cause(ctx), ErrReplicaLost) {
        t.Fatal("unexpected cancellation cause")
    }
    
    if !errors.Is(ctx.Err(), context.Canceled) {
        t.Fatal("unexpected context error")
    }

    The test verifies both contracts:

    Err()
        → standard cancellation category
    
    Cause()
        → application-specific reason

    6.16 Testing AfterFunc

    AfterFunc is asynchronous.

    This is insufficient:

    cancel()
    
    if !called {
        t.Fatal("callback not called")
    }

    The callback may simply not have run yet.

    Go's concurrency testing facilities provide testing/synctest, which can make tests involving goroutines, timers, and AfterFunc deterministic. The Go source includes an explicit Context.AfterFunc example using synctest.Wait().

    A production test can therefore express:

    synctest.Test(t, func(t *testing.T) {
        ctx, cancel := context.WithCancel(t.Context())
    
        called := false
    
        context.AfterFunc(ctx, func() {
            called = true
        })
    
        synctest.Wait()
    
        if called {
            t.Fatal("callback ran before cancellation")
        }
    
        cancel()
        synctest.Wait()
    
        if !called {
            t.Fatal("callback did not run")
        }
    })

    The test validates actual scheduling behavior rather than relying on sleeps.


    Production Reference

    The context package can be reduced to six engineering responsibilities.

    1. Lifetime

    Who owns this work?
    How long is it useful?
    When should it stop?

    Context expresses those boundaries.

    2. Cancellation

    Context cancellation
        → stop requesting work

    The operation must cooperate.

    3. Deadline

    Deadline
        → remaining execution budget

    Child operations may consume less of the budget, never more.

    4. Cause

    Err()
        → cancellation classification
    
    Cause()
        → cancellation reason

    Use causes when operational diagnosis requires more information.

    5. Metadata

    Context values
        → request-scoped metadata

    Do not turn context into configuration or dependency injection.

    6. Concurrency Boundary

    context
        → cancellation
    
    sync / channels
        → coordination and completion

    Cancellation and completion are separate states.


    Final Invariants

    A production-grade context implementation satisfies these conditions:

    Every context-aware operation receives its lifetime explicitly.
    
    Every derived context has a clear owner.
    
    Every CancelFunc is called when the derived operation ends.
    
    Every goroutine has a defined lifetime.
    
    Every blocking operation has a cancellation or timeout path.
    
    Every downstream call receives the appropriate parent or child context.
    
    No request context is stored in a long-lived object.
    
    No nil context is passed.
    
    No context is replaced with Background without an explicit lifetime decision.
    
    Deadlines are treated as finite budgets.
    
    Retries terminate when the parent context is no longer valid.
    
    Cancellation causes are preserved when they carry operational meaning.
    
    Context values contain request-scoped metadata rather than hidden dependencies.
    
    WithoutCancel is used only when cancellation detachment is intentional.
    
    Detached work receives an explicit replacement lifetime.
    
    Cancellation is never confused with completion.
    
    Completion is synchronized independently when required.

    The resulting architecture is:

                             Application
    
                            process lifetime
    
                             server lifetime
    
                            request context
    
                  ┌───────────────┼───────────────┐
                  │               │               │
                cache             DB              RPC
                  │               │               │
                  │          tighter budget    downstream
    
                  └────────── cancellation ──────────┘
    
    Context
      ├── lifetime
      ├── cancellation
      ├── deadline
      └── request metadata
    
    sync / channels
      ├── synchronization
      └── completion
    
    explicit dependencies
      └── configuration and ownership
    
    business arguments
      └── application state

    context.Context is therefore not a generic utility parameter.

    It is the standard Go contract for propagating operation lifetime across API boundaries.

    A correct implementation does not merely accept ctx.

    It preserves the lifetime semantics represented by ctx all the way to the operation that can actually block, consume resources, or continue executing after the caller no longer needs the result.