Skip to content
AgileLogics
7 min read

The context Package

Prerequisites: Level 2 — Intermediate

A context.Context carries three things across API boundaries: a cancellation signal, an optional deadline, and (sparingly) request-scoped values. Almost every blocking call in the standard library and in well-written third-party code accepts one as its first parameter, conventionally named ctx.

Creating contexts

You never build a Context from scratch. You start from a root and derive children from it:

  • context.Background() — the root, used in main, tests, and at the top of long-lived work.
  • context.TODO() — also a root, but signals "I haven't worked out which context belongs here yet".
  • context.WithCancel(parent) — returns a child plus a cancel function.
  • context.WithTimeout(parent, d) / context.WithDeadline(parent, t) — a child that cancels itself when the time is up.

Cancelling a parent cancels every child derived from it. Cancelling a child never affects the parent.

package main

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

// slowQuery simulates work that takes d, but gives up early if ctx is done.
func slowQuery(ctx context.Context, d time.Duration) (string, error) {
    select {
    case <-time.After(d):
        return "rows", nil
    case <-ctx.Done():
        return "", ctx.Err()
    }
}

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

    res, err := slowQuery(ctx, 10*time.Millisecond)
    fmt.Println(res, err)

    _, err = slowQuery(ctx, time.Second)
    fmt.Println(err)
    fmt.Println(errors.Is(err, context.DeadlineExceeded))
}

Expected output:

rows <nil>
context deadline exceeded
true

ctx.Done() returns a channel that is closed when the context is cancelled or times out; ctx.Err() then reports why — context.Canceled or context.DeadlineExceeded.

Propagating through a call chain

The point of a context is that it flows down the call stack. An HTTP server gives each request a context that is cancelled when the client disconnects; passing it along means an abandoned request stops its database query too:

func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
    user, err := h.store.FindUser(r.Context(), r.PathValue("id"))
    if err != nil {
        http.Error(w, "not found", http.StatusNotFound)
        return
    }
    writeJSON(w, http.StatusOK, user)
}

func (s *Store) FindUser(ctx context.Context, id string) (User, error) {
    var u User
    err := s.db.QueryRowContext(ctx,
        "SELECT id, name FROM users WHERE id = $1", id).Scan(&u.ID, &u.Name)
    return u, err
}

Each layer can tighten the deadline (context.WithTimeout(ctx, 2*time.Second)) but can't extend past its parent's.

Stopping goroutines

A goroutine that loops forever should watch ctx.Done() so its owner can stop it:

func produce(ctx context.Context, out chan<- int) {
    defer close(out)
    for i := 0; ; i++ {
        select {
        case out <- i:
        case <-ctx.Done():
            return
        }
    }
}

Request-scoped values — sparingly

context.WithValue attaches a key/value pair. Use it only for data that genuinely belongs to the request and crosses API boundaries — a request ID or an authenticated user — never for optional function parameters. Use an unexported key type so other packages can't collide with yours:

type ctxKey int

const requestIDKey ctxKey = 0

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

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

Common mistakes

  • Forgetting to call cancel. It releases the timer and the child's resources; defer cancel() right after creating the context. go vet flags this as lostcancel.
  • Storing a Context in a struct field. Pass it explicitly as the first parameter so each call gets the right one.
  • Passing nil as a context. Use context.TODO() if you truly have none.
  • Using WithValue as a grab bag for dependencies like a logger or database handle — those belong in struct fields or function parameters.
  • Ignoring ctx inside a long loop — cancellation is cooperative; nothing stops your code unless it checks.

Practice

Write ticker(ctx context.Context) that prints tick every 100ms until the context is done, then prints stopped: followed by the context's error. Call it from main with a 350ms timeout.

Show solution
package main

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

func ticker(ctx context.Context) {
    t := time.NewTicker(100 * time.Millisecond)
    defer t.Stop()
    for {
        select {
        case <-t.C:
            fmt.Println("tick")
        case <-ctx.Done():
            fmt.Println("stopped:", ctx.Err())
            return
        }
    }
}

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

This prints tick about three times, then stopped: context deadline exceeded. The exact tick count can vary on a busy machine, which is why the deadline sits between ticks rather than on one.