- Book: The Complete Guide to Go Programming
- Also by me: Hexagonal Architecture in Go — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You open a Go file you didn't write. The first function you land on
reads func (s *Store) GetUser(ctx context.Context, id string).
You don't know anything about Store yet, but you already know two
things. This call can be cancelled. And whatever it does, the caller
owns the deadline.
That is the whole point of the convention. context.Context goes
first, always, so a reader learns the cancellation story before the
business arguments. The Go standard library holds the line on this,
linters like revive and staticcheck flag deviations, and the
reasons behind it are worth understanding rather than cargo-culting.
The rule, stated plainly
The Go blog and the context package docs say it directly: pass
Context as the first argument, name it ctx, and do not store it
in a struct.
func (c *Client) Fetch(
ctx context.Context,
url string,
) (*Response, error)
Not second. Not last. Not optional. First.
The mechanical reason is grep-ability. When every context-aware
function in the ecosystem puts ctx in slot zero, you can read a
signature and know instantly whether the call respects cancellation.
database/sql did the split explicitly: Query has no context,
QueryContext takes one, and the context leads. net/http,
os/exec (CommandContext), and every serious library since Go 1.7
follow the same shape.
The deeper reason is that context is not a normal argument. A
string id is data the function operates on. A Context is the
scope the function runs inside. Putting the scope first mirrors how
you'd read it aloud: "within this deadline, fetch that URL."
What belongs in a context, and what does not
This is where most teams drift. context.WithValue exists, so people
reach for it, and the value bag slowly fills with things that should
have been parameters.
The line the Go docs draw: context values are for request-scoped
data that transits process and API boundaries, not for passing
optional arguments to a function.
Good candidates: a request ID for tracing, an authenticated user
identity extracted by middleware, a deadline. Things that ride along
with the request and that intermediate layers shouldn't have to name
explicitly.
Bad candidates: a database handle, a logger you were too lazy to
inject, a config struct, a feature flag the function actually
branches on. If the function reads a value to decide what to do,
that value is an argument. Make it one.
// Anti-pattern: real dependencies smuggled via context.
func handler(ctx context.Context) {
db := ctx.Value("db").(*sql.DB)
log := ctx.Value("logger").(*slog.Logger)
// now the compiler can't help you at all
}
Two things are wrong here. The db and log are dependencies, so
they belong in the struct or the constructor, checked by the
compiler. And the string key "db" is a collision waiting to happen
across packages, which is why the docs tell you to use an unexported
key type:
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
}
The unexported ctxKey type means no other package can accidentally
(or deliberately) read or overwrite your value. The typed accessor
means callers never see the Value call or the assertion. That's the
only shape of WithValue worth shipping.
Cancellation propagates down the tree
The part that makes the first-parameter rule pay off: contexts form a
tree, and cancellation flows from parent to child, never back up.
When an HTTP handler's request context is cancelled because the
client hung up, every context derived from it, and every function you
threaded it through, sees ctx.Done() close at the same moment.
That only works if the context actually reaches every layer, which
only happens if every function takes it and passes it on.
func (s *Service) Report(
ctx context.Context,
userID string,
) (*Report, error) {
user, err := s.users.Get(ctx, userID)
if err != nil {
return nil, err
}
events, err := s.events.Since(ctx, user.ID)
if err != nil {
return nil, err
}
return build(user, events), nil
}
Report doesn't check ctx.Done() itself. It doesn't need to. It
hands ctx to s.users.Get and s.events.Since, and those hand it
to the SQL driver, which cancels the in-flight query when the client
disconnects. The context is a baton. Drop it at any layer, and every
layer below that point becomes uncancellable.
This is also why you derive rather than replace. context.WithTimeout
takes a parent and returns a child bound by the tighter of the two
deadlines. A child can shorten a parent's deadline. It can never
extend it.
func (s *Service) fetchFast(
parent context.Context,
) error {
ctx, cancel := context.WithTimeout(
parent, 500*time.Millisecond,
)
defer cancel()
return s.slowThing(ctx)
}
If parent already had 200ms left, ctx gets 200ms, not 500. The
tree only ever tightens as you go down.
The struct-field anti-pattern
The one the docs call out by name: "Do not store a Context inside a
struct type." It's tempting because it looks like it saves typing.
Instead of threading ctx through ten methods, you stash it once.
// Anti-pattern.
type Worker struct {
ctx context.Context
db *sql.DB
}
func (w *Worker) Run(job Job) error {
return w.process(w.ctx, job)
}
Now Run has no ctx parameter, so the caller can't scope a single
job with its own deadline. The context was captured once, at
construction, and frozen. Every call to Run shares the same
lifetime whether that makes sense or not.
Worse is what happens with reuse. A Worker built with a request's
context, then kept alive past that request, is holding a context
that's already cancelled. The next job fails instantly with
context.Canceled and the failure looks like it came from the job,
not from the stale field.
The mental split that keeps this straight: a struct holds
dependencies (things that live as long as the struct), a method
takes a context (something scoped to one call).
type Worker struct {
db *sql.DB // dependency: lives with the Worker
}
func (w *Worker) Run(
ctx context.Context, // scope: lives with the call
job Job,
) error {
return w.process(ctx, job)
}
The db is a dependency. It belongs on the struct. The ctx is the
scope of one job. It belongs in the method signature, first, so the
next reader sees the cancellation story before the job.
There is one narrow exception the standard library itself makes:
http.Request carries a context, reachable through req.Context(),
because a request genuinely is a request-scoped object with one
lifetime. You still don't read the field directly for your own types.
When in doubt, thread the parameter.
The checklist
Four things to look for in your own Go code.
- Every function that does I/O or calls another context-aware
function takes
ctx context.Contextas its first parameter, namedctx. -
context.WithValueonly carries request-scoped data (trace IDs, caller identity) behind an unexported key type, never dependencies the function branches on. - Contexts are always derived from the incoming one, never created
fresh with
context.Background()deep inside a call chain, which would sever cancellation from the parent. - No struct stores a
context.Contextfield. Dependencies live on the struct, the context lives in the method signature.
The convention isn't bureaucracy. It's the reason a Go function you've
never seen tells you, from its signature alone, who owns its deadline.
The first-parameter rule sits at the boundary between "this is how Go
signatures read" and "this is how a service stays cancellable under
load." The Complete Guide to Go Programming goes through the
context package end to end, from the tree structure to the runtime
mechanics of how cancellation actually wakes a parked goroutine. And
Hexagonal Architecture in Go is where the same idea meets your
ports and adapters, so the context stays honest as it crosses every
boundary in the service.

Top comments (0)