Context and Cancellation

Updated

July 30, 2026

Overview

The context package manages deadlines, cancellation, and request-scoped values across API boundaries.

Creating Contexts

ctx := context.Background()  // Root context
ctx := context.TODO()        // Placeholder

Context Hierarchy & Downward Cancellation

Contexts form an immutable tree. When a parent context is canceled or times out, all child contexts derived from it are automatically canceled. However, canceling a child context does not affect the parent or siblings.

flowchart TD
    Root["context.Background() (Root)"] --> ReqCtx["reqCtx (WithValue: request_id)"]
    ReqCtx --> DBTimeout["dbCtx (WithTimeout: 2s)"]
    ReqCtx --> RPCCancel["rpcCtx (WithCancel)"]
    RPCCancel --> SubWorker1["subWorkerCtx 1"]
    RPCCancel --> SubWorker2["subWorkerCtx 2"]

    classDef cancelFill fill:#f9f,stroke:#333,stroke-width:2px;
    class RPCCancel,SubWorker1,SubWorker2 cancelFill;
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

go func() {
    select {
    case <-ctx.Done():
        fmt.Println("cancelled:", ctx.Err())
        return
    case <-time.After(time.Hour):
        fmt.Println("completed")
    }
}()

cancel()  // Signal cancellation downwards to all derived contexts

Timeout

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

select {
case result := <-doWork(ctx):
    fmt.Println(result)
case <-ctx.Done():
    fmt.Println("timeout:", ctx.Err())
}

Deadline

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

Passing Context

func handleRequest(ctx context.Context) {
    result, err := fetchData(ctx)
    if err != nil {
        return
    }
    processData(ctx, result)
}

func fetchData(ctx context.Context) (Data, error) {
    req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
    return http.DefaultClient.Do(req)
}

Context Values

type key string
const userKey key = "user"

ctx := context.WithValue(ctx, userKey, "alice")

user := ctx.Value(userKey).(string)

Best Practices

  • Pass context as first parameter
  • Never store context in structs
  • Always call cancel() (use defer)
  • Only use context values for request-scoped data

Summary

Function Purpose
WithCancel Manual cancellation
WithTimeout Duration-based timeout
WithDeadline Absolute time limit
WithValue Request-scoped data

Worked example

Context-aware worker that respects cancel and timeout.

Save as main.go. Then:

go mod init example
go run .
package main

import (
    "context"
    "fmt"
    "sync"
    "time"
)

func worker(ctx context.Context, id int, out chan<- string) {
    select {
    case <-time.After(30 * time.Millisecond):
        out <- fmt.Sprintf("worker-%d done", id)
    case <-ctx.Done():
        out <- fmt.Sprintf("worker-%d %v", id, ctx.Err())
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
    defer cancel()

    out := make(chan string, 2)
    var wg sync.WaitGroup
    for i := 1; i <= 2; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            worker(ctx, id, out)
        }(i)
    }
    wg.Wait()
    close(out)
    for msg := range out {
        fmt.Println(msg)
    }
}

Expected output: (both lines show deadline exceeded)

worker-1 context deadline exceeded
worker-2 context deadline exceeded

More examples

Propagate request IDs with typed WithValue keys.

package main

import (
    "context"
    "fmt"
)

type key int

const requestID key = 1

func handle(ctx context.Context) {
    fmt.Println("request_id:", ctx.Value(requestID))
}

func main() {
    ctx := context.WithValue(context.Background(), requestID, "req-100")
    // Child inherits values.
    child, cancel := context.WithCancel(ctx)
    defer cancel()
    handle(child)
}

Expected output:

request_id: req-100

Runnable example

Save as main.go. Then:

go mod init example
go run .
package main

import (
    "context"
    "fmt"
    "time"
)

type ctxKey string

const requestIDKey ctxKey = "request_id"

func work(ctx context.Context, name string, d time.Duration) error {
    select {
    case <-time.After(d):
        fmt.Printf("%s finished (request_id=%v)\n", name, ctx.Value(requestIDKey))
        return nil
    case <-ctx.Done():
        fmt.Printf("%s cancelled: %v\n", name, ctx.Err())
        return ctx.Err()
    }
}

func main() {
    // WithValue: request-scoped data flows downward
    root := context.WithValue(context.Background(), requestIDKey, "req-42")

    // Manual cancel
    ctx, cancel := context.WithCancel(root)
    go func() {
        time.Sleep(20 * time.Millisecond)
        cancel()
    }()
    _ = work(ctx, "cancel-demo", 200*time.Millisecond)

    // Timeout: child inherits parent values
    tctx, tcancel := context.WithTimeout(root, 15*time.Millisecond)
    defer tcancel()
    _ = work(tctx, "timeout-demo", 200*time.Millisecond)

    // Success path: finishes before deadline
    okCtx, okCancel := context.WithTimeout(root, 100*time.Millisecond)
    defer okCancel()
    _ = work(okCtx, "ok-demo", 5*time.Millisecond)

    // Parent cancel cancels children
    parent, pCancel := context.WithCancel(root)
    child, childCancel := context.WithCancel(parent)
    defer childCancel()
    pCancel()
    select {
    case <-child.Done():
        fmt.Println("child saw parent cancel:", child.Err())
    case <-time.After(time.Second):
        fmt.Println("child did not cancel (unexpected)")
    }
}

Expected output:

cancel-demo cancelled: context canceled
timeout-demo cancelled: context deadline exceeded
ok-demo finished (request_id=req-42)
child saw parent cancel: context canceled

What to notice: Always defer cancel() (or call it when done) so timers and resources are released. Cancellation propagates down the tree; canceling a child never cancels its parent. Prefer typed keys for WithValue, not bare strings.

Try next: Swap WithTimeout for WithDeadline(time.Now().Add(...)). Pass the cancelled context into a second goroutine and confirm both exit.