Context and Cancellation in Go

AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.

Go 1.24

One incoming request can touch five layers, three goroutines and two database queries. Abandoning all of them mid-flight is what context exists for — it does exactly one thing: carry a cancellation signal and a deadline down the call chain.

What a context carries

context.Context offers four things:

  1. Done() — a channel that is closed on cancellation.
  2. Err() — why it ended: context.Canceled or context.DeadlineExceeded.
  3. Deadline() — whether there is one, and when.
  4. Value(key) — request-scoped data such as a trace ID or an authenticated principal.

It is not a global variable container, and it is not a place for optional parameters.

Choosing a root

ctx := context.Background()   // never nil, never cancelled: main, init, tests
ctx := context.TODO()         // placeholder: not sure yet, must be replaced

Never pass nil. It compiles and panics at runtime. A library function that is unsure should take TODO() so the gap is visible in the call chain.

Timeouts and deadlines

ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()                       // required, or the timer leaks

res, err := fetchUser(ctx, 42)
deadline := time.Now().Add(500 * time.Millisecond)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()

The difference is only how you express the moment: WithTimeout takes a duration, WithDeadline an absolute time. Deriving from a context that already has a deadline keeps the earlier one.

defer cancel() is not optional:

  • Even a successful call must release the internal timer and child references.
  • go vet reports the obvious case of a cancel that is never called (lostcancel).

When you need to know why a context was cancelled beyond Canceled, context.WithCancelCause stores a cause that context.Cause(ctx) retrieves. That beats smuggling the reason through an error variable.

Passing it down

The convention: ctx is the first parameter, and it is called ctx.

func (s *Service) Load(ctx context.Context, id int) (*User, error) {
    row, err := s.db.QueryContext(ctx, "SELECT id, name FROM users WHERE id = ?", id)
    if err != nil {
        return nil, fmt.Errorf("load user %d: %w", id, err)
    }
    defer row.Close()
    // ...
}

Every standard-library I/O method has a ...Context variant: QueryContext, ExecContext, http.NewRequestWithContext. Using the variant without ctx is how cancellation quietly stops propagating.

Listening for cancellation

A long-running goroutine has to check in:

func worker(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 := handle(ctx, job); err != nil {
                return err
            }
        }
    }
}

Once Done() is closed it is immediately readable, so select takes that branch. Note that if handle itself blocks without observing ctx, select cannot save you — the blocking point must support cancellation.

CPU-bound loops check explicitly:

for i, item := range items {
    if i%100 == 0 {
        select {
        case <-ctx.Done():
            return ctx.Err()
        default:
        }
    }
    process(item)
}

default keeps the check non-blocking, so the loop only pays for it occasionally.

Cancellation through an HTTP handler

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()                     // cancelled when the client disconnects
    ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
    defer cancel()

    data, err := service.Load(ctx, 1)
    if errors.Is(err, context.DeadlineExceeded) {
        http.Error(w, "request timed out", http.StatusGatewayTimeout)
        return
    }
    if errors.Is(err, context.Canceled) {
        return                             // client is gone, nothing to write
    }
    // ...
}

r.Context() is cancelled when the connection closes or the handler returns, which makes it the natural request-scoped root.

Coordinating several goroutines

func fetchAll(ctx context.Context, ids []int) ([]User, error) {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel()                          // cancel the rest as soon as one fails

    var (
        wg    sync.WaitGroup
        mu    sync.Mutex
        users []User
        once  sync.Once
        err   error
    )

    for _, id := range ids {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            u, e := fetchUser(ctx, id)
            if e != nil {
                once.Do(func() { err = e; cancel() })
                return
            }
            mu.Lock()
            users = append(users, u)
            mu.Unlock()
        }(id)
    }

    wg.Wait()
    if err != nil {
        return nil, err
    }
    return users, nil
}

Real code should reach for golang.org/x/sync/errgroup, which packages the WaitGroup, the error collection and the cancellation:

g, ctx := errgroup.WithContext(ctx)
for _, id := range ids {
    g.Go(func() error { return fetchAndStore(ctx, id) })
}
if err := g.Wait(); err != nil {
    return err
}

The first non-nil error is returned by Wait, and ctx is cancelled at that moment — no sync.Once needed.

Using Value correctly

type ctxKey struct{}

func WithRequestID(ctx context.Context, id string) context.Context {
    return context.WithValue(ctx, ctxKey{}, id)
}

func RequestID(ctx context.Context) string {
    id, _ := ctx.Value(ctxKey{}).(string)
    return id
}
  • Keys are unexported custom types so no other package can collide with them.
  • Store request-scoped metadata only — trace IDs, auth principals. Not database handles, config, or mutable state.
  • Every read needs a type assertion. Overuse turns the codebase into any soup.

Keep it out of structs

// wrong
type Service struct {
    ctx context.Context
}

// right
func (s *Service) Do(ctx context.Context) error { ... }

A context describes the lifetime of one call. Stored in a struct, unrelated requests share a single cancellation signal, and one slow request fails all of them.

Common mistakes

1. Forgetting cancel()

go vet reports it as lostcancel. Use defer cancel() right after creation.

2. Expecting WithTimeout to interrupt a blocking call

It only closes the Done() channel. time.Sleep, an unbuffered send, or a third-party call that ignores ctx will keep blocking.

3. Replacing the caller’s context with Background()

// wrong: throws away the caller's deadline and cancellation
db.QueryContext(context.Background(), ...)

Always pass the received ctx down.

4. Waiting on a result without waiting on cancellation

// blocks forever after the caller gives up
res := <-ch

Write select { case res := <-ch: ...; case <-ctx.Done(): return ctx.Err() }.

5. Treating ctx.Err() as the only error

ctx.Err() explains cancellation. Business errors must be wrapped and distinguished with errors.Is.

6. Using a result after cancellation

When err != nil, the returned value may be half-written. Never treat it as valid data.

7. Timeouts shorter than the downstream

A timeout below the dependency’s P99 manufactures failures. Budget per hop: gateway 3s → service 2s → database 1.5s.

Summary

  • context carries cancellation, deadlines and request-scoped data; ctx is the first parameter.
  • Root at Background(), use TODO() when unsure, never nil.
  • Always defer cancel() after WithTimeout / WithDeadline.
  • Blocking points must observe ctx; select waits on both the result and Done().
  • No contexts in structs, no replacing the caller’s context, and Value only for metadata.

Next: goroutines and channels, where these signals actually get consumed.