Context and Cancellation

Updated

September 13, 2026

Context and Cancellation

A context is how one function tells another “this work is still wanted.” The boring default is: the first argument is ctx context.Context, you check it, and you pass it down. When the desk closes, workers stop because the context says so — not because someone pulled the power cord.

Mental model

context.Background() is the empty root. Use it in main, in tests, and at the edge of a program. context.TODO() is a placeholder when you have not wired a real context yet. Do not leave TODO in production paths.

WithCancel returns a child context and a cancel function. Calling cancel() makes ctx.Done() ready. ctx.Err() becomes context.Canceled.

WithTimeout / WithDeadline cancel automatically when time runs out. ctx.Err() is then context.DeadlineExceeded. Always defer cancel() so the timer is released if you finish early.

WithCancelCause (and context.Cause) attach a real error (“kitchen closed”) instead of the generic canceled value.

Cancel is advisory. A worker that never looks at ctx will not stop. The next chapter is about not starting goroutines you cannot stop. This chapter is the stop signal itself.

Do not store a context inside a struct that outlives one request. Pass it as the first argument.

Worked examples

Case 1: Background, first argument

Save as ctx_print.go. The printer takes ctx first even though this call never cancels. The signature is the habit.

// ctx_print.go
package main

import (
    "context"
    "fmt"
)

func printTicket(ctx context.Context, id int) error {
    if err := ctx.Err(); err != nil {
        return err
    }
    fmt.Println("printed", id)
    return nil
}

func main() {
    ctx := context.Background()
    if err := printTicket(ctx, 7); err != nil {
        fmt.Println("err:", err)
    }
}

Run:

go run ctx_print.go

Output:

printed 7

Background is never canceled. ctx.Err() is nil. The check is still the right first line for anything that might later run under a timeout.

Case 2: WithCancel stops a worker

Save as cancel_worker.go. The worker loops on tickets until ctx.Done(). main cancels, then waits.

// cancel_worker.go
package main

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

func worker(ctx context.Context, tickets <-chan int) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("stopped:", ctx.Err())
            return
        case id, ok := <-tickets:
            if !ok {
                fmt.Println("queue closed")
                return
            }
            fmt.Println("printed", id)
        }
    }
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    tickets := make(chan int)
    var wg sync.WaitGroup
    wg.Go(func() { worker(ctx, tickets) })
    cancel()
    wg.Wait()
}

Run:

go run cancel_worker.go

Output:

stopped: context canceled

tickets is never ready. Done is. The worker returns. Wait unblocks. If you omit cancel(), this program hangs. If you omit Wait, you are back to killing the worker by exiting main.

Case 3: WithTimeout

Save as timeout.go. The kitchen is slow. The desk only waits 20 milliseconds.

// timeout.go
package main

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

func kitchen(ctx context.Context) error {
    select {
    case <-time.After(time.Second):
        fmt.Println("order ready")
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
    defer cancel()
    err := kitchen(ctx)
    fmt.Println("desk:", err)
}

Run:

go run timeout.go

Output:

desk: context deadline exceeded

defer cancel() stops the timeout timer as soon as main returns. Even though the deadline already fired, calling cancel is still required — if the kitchen had finished in 1ms, you would leak the timer without it.

Case 4: WithCancelCause

Save as cause.go. The cancel reason is a desk error, not a generic string you parse later.

// cause.go
package main

import (
    "context"
    "fmt"
)

func main() {
    ctx, cancel := context.WithCancelCause(context.Background())
    cancel(fmt.Errorf("kitchen closed"))
    fmt.Println("err:", ctx.Err())
    fmt.Println("cause:", context.Cause(ctx))
}

Run:

go run cause.go

Output:

err: context canceled
cause: kitchen closed

Err stays context.Canceled. Cause is the error you passed. errors.Is(ctx.Err(), context.Canceled) is still true. Use Cause when a human or a log needs the why.

Case 5: Timeout plus a worker that checks

Save as prep.go. Same worker shape as Case 2, deadline as Case 3.

// prep.go
package main

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

func prep(ctx context.Context) {
    select {
    case <-time.After(time.Second):
        fmt.Println("prep done")
    case <-ctx.Done():
        fmt.Println("prep stopped:", context.Cause(ctx))
    }
}

func main() {
    ctx, cancel := context.WithTimeoutCause(
        context.Background(),
        20*time.Millisecond,
        fmt.Errorf("shift ended"),
    )
    defer cancel()
    var wg sync.WaitGroup
    wg.Go(func() { prep(ctx) })
    wg.Wait()
}

Run:

go run prep.go

Output:

prep stopped: shift ended

WithTimeoutCause is Go 1.21+. Cause is shift ended. Err would still be context.DeadlineExceeded. Pass the same ctx into every function that should stop together.

The trap

Save as ignore_ctx.go. The worker takes a context and never looks at it. Cancel does nothing. main waits on a channel that nobody receives from — hang — so we use a timeout on main to show the worker is still alive.

// ignore_ctx.go
package main

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

func worker(ctx context.Context, out chan<- string) {
    time.Sleep(50 * time.Millisecond)
    out <- "still working"
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    out := make(chan string, 1)
    go worker(ctx, out)
    cancel()
    select {
    case msg := <-out:
        fmt.Println(msg)
    case <-time.After(200 * time.Millisecond):
        fmt.Println("main gave up")
    }
}

Run:

go run ignore_ctx.go

Output:

still working

Cancel ran. The worker slept anyway and sent. That is a leak of work and, if out were unbuffered and main had moved on, a leaked goroutine blocked on send. Checking ctx is not decoration. The fix is Case 2: select on ctx.Done() and the real work.

The boring rule

  • First argument: ctx context.Context.
  • Create with Background at the edge; derive cancel/timeout children below.
  • defer cancel() every time you get a cancel function.
  • Workers select on ctx.Done() (or return ctx.Err() at the top of a loop).
  • Prefer WithCancelCause / WithTimeoutCause when the reason matters in logs.
  • Do not put optional parameters in context.Value. A function argument is cheaper and typed.
  • Do not store ctx on a long-lived struct.

Try this

  1. In cancel_worker.go, send 7 on a buffered tickets channel before cancel(). Run it a few times. You may see printed 7 then stopped, or only stopped. Then send after the worker is running and before cancel, with Wait still at the end.
  2. In timeout.go, raise the timeout to 2 * time.Second. Confirm order ready and desk: <nil>.
  3. In cause.go, print context.Cause(ctx) before cancel. It should match ctx.Err() (nil until cancel).
  4. Fix ignore_ctx.go: in worker, select on <-ctx.Done() and time.After(50 * time.Millisecond). After cancel(), you should print a stop path, not still working.