Skip to content
Back to Blog

Go's context Package, Done Right

Cancellation, deadlines and request-scoped values in Go look simple until a goroutine leaks in production. Here's how I use context.Context without shooting myself in the foot.

Sun7 min read

Almost every Go function I write for a backend service takes ctx context.Context as its first argument. New Go developers often find this noisy at first. I did too. Why should a function that runs a SQL query care about some mysterious context object?

The answer is that a backend request isn't a single function call. It's a tree of work: an HTTP handler calls a service, the service calls a database and two other APIs, and one of those calls fans out into goroutines. When the client disconnects or a deadline passes, you want the whole tree to stop. context is how Go carries that "please stop" signal through the tree.

What a context actually carries

A context.Context carries three things:

  1. A cancellation signal. ctx.Done() returns a channel that closes when the work should stop.
  2. A deadline, if one was set. ctx.Deadline() tells you when.
  3. Request-scoped values, via ctx.Value(key).

That's all. It isn't a dependency container, a logger holder or a place to stash config. Keeping that list short will save you a lot of confusion later.

Contexts form a tree. You derive a child from a parent with WithCancel, WithTimeout, WithDeadline or WithValue. Cancelling a parent cancels all of its children, but cancelling a child never touches the parent.

Respecting cancellation in your own code

Most of the time you just pass ctx down, and the standard library does the rest. http.NewRequestWithContext, db.QueryContext and friends all watch ctx.Done() for you.

Where you have to think is in your own loops and blocking operations. A worker that ignores the context will happily keep running after everyone upstream has given up:

func consume(ctx context.Context, jobs <-chan Job) error {
	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case job, ok := <-jobs:
			if !ok {
				return nil // channel closed, we're done
			}
			if err := handle(ctx, job); err != nil {
				return err
			}
		}
	}
}

The select is the important part. Any time a goroutine might block on a channel, give it a way out through ctx.Done(). If you don't, you've written a goroutine leak that only shows up under load.

ctx.Err() returns context.Canceled if someone called cancel, or context.DeadlineExceeded if the deadline passed. Those two errors tell you very different things when you're debugging, so return them rather than swallowing them.

Timeouts belong at the edges

A timeout without a caller to enforce it is just a guess. I set deadlines in two places:

  • At the entry point, where I know the budget for the whole request. An HTTP server might give every request five seconds.
  • Around each outbound call, so that one slow dependency can't use up the whole budget.
func (s *Service) GetProfile(ctx context.Context, userID string) (*Profile, error) {
	ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
	defer cancel()

	return s.profiles.Fetch(ctx, userID)
}

Two details here matter more than they look.

First, always call cancel, usually with defer. It releases the timer and the resources tied to the child context. go vet has a lostcancel check that flags a discarded cancel function, and it's right to complain.

Second, a child can only shorten the deadline, never extend it. If the incoming context has 200ms left, WithTimeout(ctx, 800*time.Millisecond) still ends in 200ms. That's usually what you want: the caller's budget wins.

Say why you cancelled

For years, a cancelled context only told you context canceled, which is about as useful as a smoke alarm that doesn't say which room is on fire. Go 1.20 added WithCancelCause, and Go 1.21 added WithTimeoutCause and WithDeadlineCause:

ctx, cancel := context.WithCancelCause(parent)
defer cancel(nil)

// somewhere, when a dependency fails hard:
cancel(fmt.Errorf("inventory service unavailable: %w", err))

// and later, in a goroutine that noticed ctx.Done():
log.Printf("stopping: %v", context.Cause(ctx))

ctx.Err() still returns plain context.Canceled, so existing checks keep working. context.Cause(ctx) gives you the real reason. When I'm reading logs at 2am, that difference matters a lot.

Work that must outlive the request

Here's a bug I see a lot in code reviews. A handler kicks off some background work, such as sending an audit event or warming a cache, and passes the request's context into it:

// Buggy: the audit write is cancelled as soon as the response is sent.
go s.audit.Record(r.Context(), event)

The request context is cancelled as soon as the handler returns, so the background work gets killed partway through. The old fix was context.Background(), but that throws away the values you might need, such as trace IDs.

Go 1.21 added context.WithoutCancel, which keeps the parent's values but drops its cancellation and deadline:

bg := context.WithoutCancel(r.Context())
go func() {
	ctx, cancel := context.WithTimeout(bg, 5*time.Second)
	defer cancel()
	s.audit.Record(ctx, event)
}()

Notice that I added a fresh timeout. Detached work still needs its own limit, or you've just moved the leak somewhere else.

The same release also added context.AfterFunc(ctx, f), which runs f in its own goroutine once ctx is done. It's handy for cleanup such as closing a connection when a request is abandoned, without writing another select loop.

Context values: use them sparingly

context.WithValue is the part of the package that gets abused most. My rule: values are for request-scoped data that crosses API boundaries and that the code in between doesn't need to know about. Trace IDs, request IDs and the authenticated principal fit. Database handles, loggers you could inject and optional function parameters don't.

If a function needs a piece of data to do its job, make it a parameter. Hiding required inputs in the context turns a compile-time error into a runtime nil.

When you do use values, use an unexported key type so nobody else can collide with you:

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 typed getter matters. Callers never touch ctx.Value directly, and the comma-ok type assertion means a missing value comes back as false instead of a panic.

The short list of rules

After enough code reviews, it comes down to this:

DoDon't
Pass ctx as the first parameter, named ctxStore a context in a struct field
defer cancel() right after deriving a contextPass a nil context. Use context.TODO() if you're unsure
Check ctx.Done() in every blocking selectUse values for required function inputs
Set timeouts at the edges and around outbound callsReuse a request context for background work
Record a cause when cancelling for a reasonIgnore ctx.Err() when returning early

The rule about not storing contexts in structs comes straight from the package docs. A context belongs to one operation, and a struct usually lives much longer than that. If you're tempted, it's usually a sign that the method should take ctx as an argument instead.

Wrapping up

context is a small package with a big job. Treat it as the thread that ties one unit of work together: pass it everywhere, cancel it deliberately, give it a deadline you can justify, and keep its values boring. Do that and most of the scary concurrency bugs in Go services, like leaked goroutines, hung requests and runaway retries, have a clear place to be fixed.


Want to talk Go or backend architecture? Drop me a message on the contact page.