Skip to content
C Codeloom
Go

Go context Package Explained

How to use Go's context package effectively: cancellation, deadlines, propagation, request-scoped values, and the patterns that keep services responsive.

·5 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • What context.Context actually is
  • How cancellation propagates through call trees
  • Deadlines and timeouts
  • Request-scoped values and when to use them
  • Common context pitfalls

Prerequisites

  • Basic familiarity with the language

The context package is one of those Go features that looks bureaucratic until you have shipped a service without it. Once you have, you appreciate why every long-running function in modern Go takes a ctx as its first argument. Context is how cancellation, deadlines, and request-scoped data flow through a program.

What context is

A context.Context is an interface with four methods.

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key any) any
}

Three of those are about cancellation. Done() returns a channel that closes when the context is canceled. Err() tells you why. Deadline() returns the time, if any, after which the context will cancel itself.

The fourth, Value(), lets a context carry request-scoped data along the call chain.

Creating contexts

You usually start from context.Background() (the root) or context.TODO() (a placeholder when you have not decided yet). From a root, you derive children with extra behavior.

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

ctx2, cancel2 := context.WithTimeout(ctx, 2*time.Second)
defer cancel2()

ctx3, cancel3 := context.WithDeadline(ctx, time.Now().Add(time.Minute))
defer cancel3()

ctx4 := context.WithValue(ctx, "userID", "u_42")

Each derivation returns a new context that inherits from its parent. Canceling the parent cancels all descendants.

cancel is a function. Always call it (usually with defer), even if the context will time out on its own. Releasing it prevents a small leak in the runtime’s bookkeeping.

The mental model

Context propagates downward through your call tree like a tree of switches.

Background
 |
 v
WithTimeout(2s)         <- handler entry
 |
 +--> HTTP call (ctx) <- propagates timeout
 |
 +--> DB query (ctx)  <- propagates timeout
          |
          v
      WithCancel(ctx) <- subquery
          |
          +--> goroutine 1 (ctx)
          +--> goroutine 2 (ctx)
Context propagation through a call tree

When the root cancels (timeout, client disconnect), the signal flows to every leaf at once. Every operation gets a chance to abort its work and return.

Listening for cancellation

A function that does long-running work selects on ctx.Done().

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

When the context is canceled, Done() closes, the select takes that branch, and the function exits with ctx.Err() (either Canceled or DeadlineExceeded).

Many standard library functions accept a context and respect cancellation natively. http.Request has WithContext. The database/sql package accepts contexts on QueryContext, ExecContext, and friends. The right idiom is to pass the request’s context all the way down.

A hands-on example

An HTTP handler with a downstream API call that should be bounded.

func handler(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
    defer cancel()

    req, _ := http.NewRequestWithContext(ctx, "GET", upstream, nil)
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        if ctx.Err() == context.DeadlineExceeded {
            http.Error(w, "upstream timeout", http.StatusGatewayTimeout)
            return
        }
        http.Error(w, err.Error(), http.StatusBadGateway)
        return
    }
    defer resp.Body.Close()
    io.Copy(w, resp.Body)
}

If the upstream takes more than three seconds, the HTTP client cancels the request, the body read returns an error, and the handler responds to the client. If the client disconnects, r.Context() is canceled by the server, and both the timeout and our downstream call cancel as well.

Request-scoped values

context.WithValue lets you carry a value along the chain.

type ctxKey string
const userKey ctxKey = "user"

ctx = context.WithValue(ctx, userKey, currentUser)

// later
u, _ := ctx.Value(userKey).(*User)

This is the right way to propagate things like request IDs, authenticated users, or tracing spans across boundaries that you do not control.

It is the wrong way to pass ordinary function arguments. If the value is required for the function’s main job, pass it explicitly. Context values are opaque, undocumented, and easy to lose.

Use a private, unexported key type to avoid collisions between packages.

Common pitfalls

Storing context in a struct. The standard library docs warn against it, and they mean it. Context belongs in the call chain, not the data model. Pass it as the first argument of every function that might need it.

Forgetting to call cancel. The garbage collector will eventually clean things up, but you can leak goroutines waiting on Done() in the meantime. Always defer cancel().

Ignoring ctx.Err(). After a select on ctx.Done(), return ctx.Err() so callers know what happened.

Using context.Background() deep in a call stack to “reset” the parent. That bypasses the caller’s cancellation and turns a tidy hierarchy into a leak.

Treating WithValue as a global. Anything that has to be retrieved everywhere should be a function parameter.

Practical tips

Make ctx context.Context the first parameter of any function that does I/O, blocks, or spawns goroutines. Pass r.Context() from HTTP handlers. Wrap it with WithTimeout near boundaries where you control the budget.

Treat ctx.Err() == context.Canceled and ctx.Err() == context.DeadlineExceeded as distinct cases when logging. They mean different things to the caller and to your monitoring.

In tests, use context.Background() with explicit timeouts. Avoid context.TODO() in shipped code.

Wrap-up

Context is the missing glue between requests and goroutines in Go. It carries cancellation, deadlines, and a controlled amount of request-scoped data. Pass it as the first argument, listen on Done(), set timeouts at boundaries, and defer cancel. The reward is a service that shuts down cleanly under load, releases resources promptly, and gives clients honest answers when work cannot be completed in time.