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 inmain, 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 acancelfunction.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 vetflags this aslostcancel. - Storing a
Contextin a struct field. Pass it explicitly as the first parameter so each call gets the right one. - Passing
nilas a context. Usecontext.TODO()if you truly have none. - Using
WithValueas a grab bag for dependencies like a logger or database handle — those belong in struct fields or function parameters. - Ignoring
ctxinside 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.