Go context.Context Done Right: Cancellation, Timeouts, and Values
Go context is control flow, not storage.
Go’s context.Context is simple enough to use badly — and that is the problem.
Most Go developers learn the surface rules quickly: pass context as the first argument, check ctx.Done(), use context.WithTimeout, and never pass nil.
func DoSomething(ctx context.Context) error {
// ...
}
Those rules are useful, but they cover the easy part. In production services, context is not just a parameter convention — it is the control plane for request lifetime.

Context tells work when to stop, how long it has left, which cancellation path was taken, and which request-scoped values need to travel across API boundaries. Used well, it prevents goroutine leaks, avoids wasted work, propagates deadlines, and makes services easier to shut down. Used badly, it becomes a bag of hidden dependencies, fake globals, forgotten timeouts, leaked timers, and confusing cancellation behavior.
The slightly opinionated version is this: use context for cancellation, deadlines, and request-scoped metadata, and do not use it as a dependency container.
What context is for
The context package has three main jobs — cancellation, deadlines and timeouts, and request-scoped values — and those three jobs cover everything it is designed to do.
A context should answer questions like:
Has this request been canceled?
How much time does this operation have left?
What request ID should be attached to logs?
Which authenticated user is associated with this request?
A context should not answer questions like:
Where is my database connection?
Where is my logger?
Where is my configuration?
Which service implementation should I use?
Those are dependencies — pass them explicitly through function parameters (see Dependency Injection in Go for patterns on doing this cleanly). Context is for request lifetime and request metadata, not application wiring.
The basic context shape
The core interface is small:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
The important parts are:
Done()is closed when the context is canceled or its deadline expires.Err()explains why the context ended.Deadline()tells you whether the context has a deadline.Value()stores request-scoped data.
Most code does not implement this interface. It receives a context and passes it down.
The first rule: pass context explicitly
For functions that do request-scoped or cancelable work, pass context as the first parameter — this is the standard Go convention and what every library and tool in the ecosystem expects:
func GetUser(ctx context.Context, id string) (*User, error) {
// ...
}
Do this for functions that may:
- Call a database
- Call another service
- Wait on a queue
- Start background work
- Block on I/O
- Use a timeout
- Need request-scoped values
- Need cancellation
Do not add context to tiny pure functions that do not need it.
This is fine:
func NormalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
Not every function needs a context. Adding context everywhere makes code noisy.
Do not store context in structs
Storing a context in a struct is one of the most common mistakes in Go codebases, and it is worth calling out explicitly. Do not do this:
type UserService struct {
ctx context.Context
db *sql.DB
}
Do this instead:
type UserService struct {
db *sql.DB
}
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
// ...
}
A context belongs to a request, operation, or task, while a service struct usually lives much longer than any single request. Mixing those lifetimes makes cancellation unclear and makes it hard to reason about which operation a context belongs to.
There are rare exceptions for types that genuinely represent a single operation lifetime, but they are rare enough that the default rule should be simple:
Pass context. Do not store it.
Do not pass nil context
Never pass nil as a context.
Bad:
err := svc.DoWork(nil)
Use context.Background() when there is no existing context:
err := svc.DoWork(context.Background())
In tests, use the test context when possible:
func TestDoWork(t *testing.T) {
err := svc.DoWork(t.Context())
if err != nil {
t.Fatal(err)
}
}
A nil context can panic when code calls methods on it. A background context is explicit and safe.
Background, TODO, and request contexts
There are three common starting points.
context.Background
Use context.Background() at the top level of a program when no parent context exists — it is the root context from which all child contexts are derived:
func main() {
ctx := context.Background()
_ = run(ctx)
}
or:
func TestSomething(t *testing.T) {
ctx := context.Background()
_ = ctx
}
context.TODO
Use context.TODO() when you know a context should be used but have not decided which one yet.
ctx := context.TODO()
This is useful during migration, but it should not become permanent if a real context exists.
Request context
In HTTP servers, use the request context:
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
_ = ctx
}
The request context is canceled when the client connection closes, the request is canceled, or the server finishes handling the request.
For web services, this is usually the context you should pass down to application code.
Cancellation with context.WithCancel
Use context.WithCancel when you want to stop work explicitly.
ctx, cancel := context.WithCancel(parent)
defer cancel()
The returned cancel function cancels the child context and releases resources associated with it. Always call it when you are done — even if the context will eventually time out, calling cancel early avoids keeping resources alive longer than necessary.
Example:
func RunWorker(parent context.Context) error {
ctx, cancel := context.WithCancel(parent)
defer cancel()
done := make(chan error, 1)
go func() {
done <- doBackgroundWork(ctx)
}()
select {
case <-ctx.Done():
return ctx.Err()
case err := <-done:
return err
}
}
The pattern is simple:
- Derive a child context.
- Defer cancel.
- Pass the child context to work that should stop together.
- Watch
ctx.Done().
Timeouts with context.WithTimeout
Use context.WithTimeout when an operation has a maximum duration.
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()
Example with an HTTP client:
func FetchUser(ctx context.Context, client *http.Client, url string) (*http.Response, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
return client.Do(req)
}
This makes the timeout part of the operation, not a hidden global setting.
Always call cancel
When you call WithCancel, WithTimeout, or WithDeadline, always call the returned cancel function — this matters for correctness.
Good:
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()
Bad:
ctx, _ := context.WithTimeout(parent, 5*time.Second)
Failing to call cancel can keep timers and child contexts alive longer than needed.
Deadlines vs timeouts
A timeout is relative:
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()
A deadline is absolute:
deadline := time.Now().Add(2 * time.Second)
ctx, cancel := context.WithDeadline(parent, deadline)
defer cancel()
Most application code uses timeouts. Deadlines are useful when a request has a fixed end time that should be shared across multiple operations — for example, if a request has 900 milliseconds left, do not give each downstream call a fresh 1-second timeout; propagate the remaining budget instead.
Timeout budgets across service layers
A common mistake is stacking timeouts blindly.
func Handler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
_ = service.DoWork(ctx)
}
func (s *Service) DoWork(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
return s.repo.Query(ctx)
}
This looks harmless, but it hides the real budget. The service layer should usually respect the caller’s deadline instead of resetting the timer to the same value.
A better pattern is:
func Handler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if err := service.DoWork(ctx); err != nil {
// handle error
return
}
}
Then inside the service:
func (s *Service) DoWork(ctx context.Context) error {
return s.repo.Query(ctx)
}
Add a child timeout only when a sub-operation needs a smaller budget:
func (s *Service) DoWork(ctx context.Context) error {
queryCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()
return s.repo.Query(queryCtx)
}
The right mental model is straightforward: the whole request has one outer budget, specific sub-operations may have smaller budgets carved out of that budget, and no layer silently extends the request beyond what the caller intended.
Check ctx.Err() to distinguish cancellation from timeout
When a context ends, ctx.Err() returns the reason.
Usually it is one of:
context.Canceled
context.DeadlineExceeded
Example:
select {
case <-ctx.Done():
return ctx.Err()
case result := <-resultCh:
return handle(result)
}
This lets callers distinguish cancellation from timeout, and that distinction matters in practice. A canceled request often means the client disconnected, while a deadline-exceeded error usually means your service was too slow — they should not always be logged, retried, or reported the same way.
Use context.Cause for better cancellation reasons
Modern Go also supports cause-aware cancellation.
The useful functions include:
context.WithCancelCausecontext.WithTimeoutCausecontext.WithDeadlineCausecontext.Cause
Plain ctx.Err() tells you the broad reason: canceled or deadline exceeded.
context.Cause(ctx) can tell you the more specific cause.
Example:
var ErrShutdown = errors.New("server shutting down")
func Run(ctx context.Context) error {
ctx, cancel := context.WithCancelCause(ctx)
defer cancel(nil)
go func() {
// Some shutdown signal arrived.
cancel(ErrShutdown)
}()
<-ctx.Done()
return context.Cause(ctx)
}
Use cause-aware cancellation when the reason matters to callers, logs, or cleanup behavior, and avoid it where a plain ctx.Err() is enough — the extra detail is only worth it when diagnosis genuinely requires it.
HTTP server example
A normal HTTP handler should start from r.Context(). For a full walkthrough of structuring Go HTTP services, see Building REST APIs in Go.
func GetUserHandler(svc *UserService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := r.PathValue("id")
user, err := svc.GetUser(ctx, id)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, user)
}
}
The service should accept and propagate the context:
type UserService struct {
repo *UserRepository
}
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
return s.repo.GetUser(ctx, id)
}
The repository should use context-aware database methods:
type UserRepository struct {
db *sql.DB
}
func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
const query = `
select id, email, name
from users
where id = $1
`
var user User
err := r.db.QueryRowContext(ctx, query, id).Scan(
&user.ID,
&user.Email,
&user.Name,
)
if err != nil {
return nil, err
}
return &user, nil
}
The important thing is the chain — each layer passes the same context down to the next:
Do not break the chain by creating context.Background() in the middle.
The context.Background() mistake: breaking the cancellation chain
This is a common bug:
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
return s.repo.GetUser(context.Background(), id)
}
This discards all cancellation and deadline information from the caller. If the client disconnects, the database query keeps running. If the request times out, the downstream work may still be in flight. If the server is shutting down, this code ignores it entirely. Replacing the received context with context.Background() inside business logic is almost always wrong.
Use the context you were given:
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
return s.repo.GetUser(ctx, id)
}
Only use context.Background() at the edge where no parent context exists.
HTTP client example
For outbound HTTP requests, attach the context to the request.
func CallAPI(ctx context.Context, client *http.Client, endpoint string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
return client.Do(req)
}
Do not do this:
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
That creates a request without the operation context.
Also avoid relying only on http.Client.Timeout. It can be useful as a safety limit, but request contexts give you better propagation across the call chain.
A common pattern is:
func CallAPI(ctx context.Context, client *http.Client, endpoint string) (*http.Response, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
return client.Do(req)
}
Use this when the downstream API call has a specific budget inside a larger request.
Database example
Most Go database APIs have context-aware methods. For a broader look at how Go data access libraries handle context — including GORM, Ent, Bun, and sqlc — see Comparing Go ORMs for PostgreSQL.
Use them.
Good:
rows, err := db.QueryContext(ctx, query, args...)
Good:
err := db.QueryRowContext(ctx, query, id).Scan(&name)
Good:
result, err := db.ExecContext(ctx, query, args...)
Bad:
rows, err := db.Query(query, args...)
The context-aware forms allow database operations to stop when the request is canceled or times out, which is especially important for slow queries, overloaded databases, and user-facing APIs where latency directly affects user experience.
Transactions and context
Transactions need careful context handling.
A transaction should usually begin with the operation context:
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
Then use the same context for transaction operations:
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
Be careful with timeouts around transactions. If the context is canceled before Commit, the transaction may be rolled back. That may be what you want, but it should be intentional.
For long transactions, the better answer is usually not a longer timeout — it is a shorter transaction that does less work per unit.
Background workers and context
Background workers should receive a context that represents their lifetime.
Example:
type Worker struct {
logger *slog.Logger
}
func (w *Worker) Run(ctx context.Context) error {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
if err := w.doOnce(ctx); err != nil {
w.logger.Error("worker iteration failed", "err", err)
}
}
}
}
This worker stops cleanly when the context is canceled, and its ticker is properly cleaned up via defer ticker.Stop(). In main, you would create a root context tied to OS signals:
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
worker := &Worker{logger: slog.Default()}
if err := worker.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
slog.Error("worker stopped", "err", err)
}
}
This is context used correctly: it describes the lifetime of the process work, and when the OS sends a signal, the whole tree of goroutines that share this context will stop together.
Preventing goroutine leaks with context cancellation
A goroutine leak happens when a goroutine remains blocked forever after it is no longer useful.
Context helps prevent this.
Bad:
func StartWorker() {
go func() {
for {
doWork()
time.Sleep(time.Second)
}
}()
}
This goroutine has no shutdown path.
Better:
func StartWorker(ctx context.Context) {
go func() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
doWork()
}
}
}()
}
Any goroutine that loops should almost always have a cancellation path.
That does not mean every goroutine must receive context directly, but the system should have a clear way to stop it.
context.AfterFunc
context.AfterFunc runs a function after a context is canceled.
It can be useful for cleanup, unblocking operations, or bridging APIs that do not natively support context.
Example:
func waitWithContext(ctx context.Context, ch <-chan struct{}) error {
stop := context.AfterFunc(ctx, func() {
// Wake up or clean up if needed.
})
defer stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-ch:
return nil
}
}
Use AfterFunc carefully — it starts logic when cancellation happens, which can make control flow harder to follow. For most application code, a normal select on ctx.Done() is clearer and easier to reason about. AfterFunc is most valuable when you need to adapt context cancellation to an API that does not already accept context.
context.WithoutCancel
context.WithoutCancel creates a context that is not canceled when the parent is canceled.
This is useful, but it is also easy to misuse.
Example use case:
func Handler(audit *AuditLog) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Handle request...
_ = ctx
auditCtx := context.WithoutCancel(ctx)
go func() {
ctx, cancel := context.WithTimeout(auditCtx, 2*time.Second)
defer cancel()
_ = audit.Write(ctx, "request completed")
}()
}
}
The idea is that the audit write may need to continue briefly even after the request context is canceled. This should be rare and deliberate — do not use WithoutCancel as a way to avoid dealing with cancellation. Use it only when the child work genuinely must outlive the parent cancellation, and always add a new timeout: a context that ignores cancellation but carries no deadline can easily create background goroutine leaks.
Context values done right
Context values are for request-scoped data that crosses API boundaries.
Good examples:
- request ID
- trace ID
- authenticated user ID
- tenant ID
- locale
- security principal
- correlation metadata
Bad examples:
- database connection
- logger as a hidden dependency
- feature flags for ordinary control flow
- optional function parameters
- configuration
- service clients
A useful rule: if the value is part of the request’s identity or observability context, it may belong in context. If it is a dependency your code needs to do its job, pass it explicitly.
Use typed keys for context values
Do not use plain strings as context keys.
Bad:
ctx = context.WithValue(ctx, "userID", "123")
This can collide with other packages.
Use an unexported custom key type:
type userIDKey struct{}
func WithUserID(ctx context.Context, userID string) context.Context {
return context.WithValue(ctx, userIDKey{}, userID)
}
func UserIDFromContext(ctx context.Context) (string, bool) {
userID, ok := ctx.Value(userIDKey{}).(string)
return userID, ok
}
This pattern gives you type safety at the package boundary, avoids key collisions with other packages, and keeps the context API surface clean with typed accessor functions.
Do not use context values for optional parameters
This is bad:
ctx = context.WithValue(ctx, "pageSize", 100)
users, err := repo.ListUsers(ctx)
This hides the function contract.
Prefer explicit parameters:
users, err := repo.ListUsers(ctx, ListUsersOptions{
PageSize: 100,
})
Context values should not replace function arguments. Hidden input makes code harder to understand, test, and review — and anyone reading the function signature will have no idea the parameter even exists.
Logging and context
There are two common approaches to logging with context. The examples here use Go’s log/slog package — for a deeper dive into structured logging with slog in production services, see Structured Logging in Go with slog.
Approach 1: Extract values and attach them to logs
func LogRequest(ctx context.Context, logger *slog.Logger, msg string) {
if requestID, ok := RequestIDFromContext(ctx); ok {
logger = logger.With("request_id", requestID)
}
logger.Info(msg)
}
This keeps the logger explicit as a proper dependency and uses context only for request-scoped values that legitimately need to cross API boundaries.
Approach 2: Store logger in context
Some codebases store a logger in context.
This can be convenient, but I do not recommend it as a default. It turns context into a dependency container.
My preference:
- Pass logger dependencies explicitly.
- Store trace IDs and request IDs in context.
- Add those values to logs at boundaries or middleware.
This keeps dependencies visible.
Context and tracing
Tracing is one of the strongest use cases for context values, and it is a genuinely good fit. OpenTelemetry and similar systems use context to propagate trace spans across function calls and process boundaries, because trace data is exactly the kind of request-scoped metadata context was designed to carry.
A typical pattern looks like:
func (s *Service) DoWork(ctx context.Context) error {
ctx, span := s.tracer.Start(ctx, "Service.DoWork")
defer span.End()
return s.repo.Query(ctx)
}
The context carries the active trace span, and the repository can create a child span from it. Each layer adds its own span without any explicit passing of tracer objects — the context does that work transparently across the entire call tree.
Error handling with context
When an operation stops because of context cancellation, preserve that information. The patterns here complement the broader error design strategies covered in Go Error Handling Architecture.
Example:
err := svc.DoWork(ctx)
if err != nil {
if errors.Is(err, context.Canceled) {
// Client canceled or caller stopped the work.
return err
}
if errors.Is(err, context.DeadlineExceeded) {
// Timeout.
return err
}
return err
}
Do not blindly wrap context errors in a way that hides them.
Wrapping with %w preserves errors.Is, so callers can still detect cancellation or timeout:
if err != nil {
return fmt.Errorf("query user: %w", err)
}
Replacing the error entirely discards that information and breaks any caller that checks for specific context error types:
if err != nil {
return errors.New("query user failed")
}
Mapping context errors to HTTP responses
Context errors often map to different HTTP outcomes.
Example:
func writeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, context.Canceled):
// The client likely went away.
// Some systems log this as a client closed request.
return
case errors.Is(err, context.DeadlineExceeded):
http.Error(w, "request timed out", http.StatusGatewayTimeout)
return
default:
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
Do not treat client cancellation as an application failure — if the user closed the browser tab, that is not your service misbehaving, and logging it as an error adds noise without signal.
Context in middleware
HTTP middleware is a common place to add request-scoped values.
Example request ID middleware:
type requestIDKey struct{}
func WithRequestID(ctx context.Context, requestID string) context.Context {
return context.WithValue(ctx, requestIDKey{}, requestID)
}
func RequestIDFromContext(ctx context.Context) (string, bool) {
requestID, ok := ctx.Value(requestIDKey{}).(string)
return requestID, ok
}
func RequestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := r.Header.Get("X-Request-ID")
if requestID == "" {
requestID = newRequestID()
}
ctx := WithRequestID(r.Context(), requestID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
This is a good use of context. The request ID belongs to the request, it should travel through the full call chain, and attaching it to logs and traces at every layer is exactly the kind of cross-cutting observability concern that context values are designed to support.
Context in tests
In tests, avoid using context.Background() blindly.
Prefer t.Context() when the work belongs to the test lifetime:
func TestService(t *testing.T) {
ctx := t.Context()
err := service.DoWork(ctx)
if err != nil {
t.Fatal(err)
}
}
For timeout behavior, test with a real timeout only if the timeout is small and meaningful.
For concurrent and time-dependent code, consider using testing/synctest — Testing Concurrent Go Code with synctest covers this tool in depth:
func TestTimeout(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
defer cancel()
time.Sleep(30 * time.Second)
if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
t.Fatalf("got %v, want deadline exceeded", ctx.Err())
}
})
}
This lets you test real timeout values without waiting for real time.
Context and errgroup
For groups of goroutines that should cancel together, errgroup is often a good fit.
Example:
func FetchAll(ctx context.Context, ids []string, client *Client) error {
g, ctx := errgroup.WithContext(ctx)
for _, id := range ids {
id := id
g.Go(func() error {
_, err := client.Fetch(ctx, id)
return err
})
}
return g.Wait()
}
If one goroutine returns an error, the group context is canceled and other goroutines that respect ctx.Done() can stop early. This is far cleaner than manually managing multiple goroutines, channels, and cancellation paths. The key phrase here is “respect the context” — errgroup cannot stop work that ignores ctx.Done().
Graceful shutdown
Context is central to graceful shutdown.
A typical server setup has:
- a root context canceled by OS signals
- an HTTP server
- background workers
- a shutdown timeout
- cleanup logic
Example:
func main() {
root, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
server := &http.Server{
Addr: ":8080",
Handler: routes(),
}
go func() {
<-root.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
slog.Error("server shutdown failed", "err", err)
}
}()
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("server failed", "err", err)
os.Exit(1)
}
}
Notice that the shutdown context is not the same as the root context — the root is already canceled when the OS signal arrives. A separate timeout context gives the shutdown process a bounded amount of time to drain in-flight requests before force-quitting, which is the subtle but important distinction that makes graceful shutdown actually work.
Common anti-patterns
Anti-pattern 1: Using context as a dependency container
Bad:
ctx = context.WithValue(ctx, "db", db)
ctx = context.WithValue(ctx, "logger", logger)
ctx = context.WithValue(ctx, "config", cfg)
Pass dependencies explicitly.
Anti-pattern 2: Creating context.Background inside business logic
Bad:
func (s *Service) DoWork(ctx context.Context) error {
return s.repo.Save(context.Background())
}
This breaks cancellation propagation.
Anti-pattern 3: Forgetting cancel
Bad:
ctx, _ := context.WithTimeout(parent, time.Second)
Good:
ctx, cancel := context.WithTimeout(parent, time.Second)
defer cancel()
Anti-pattern 4: Putting optional parameters in context
Bad:
ctx = context.WithValue(ctx, "includeDeleted", true)
Use explicit options structs.
Anti-pattern 5: Passing context too deep into pure code
Bad:
func Add(ctx context.Context, a, b int) int {
return a + b
}
Pure computation does not need context unless it is long-running or cancelable.
Anti-pattern 6: Ignoring cancellation in loops
Bad:
for item := range items {
process(item)
}
Better:
for item := range items {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if err := process(ctx, item); err != nil {
return err
}
}
Anti-pattern 7: Swallowing context errors
Bad:
if err != nil {
return errors.New("operation failed")
}
Good:
if err != nil {
return fmt.Errorf("operation failed: %w", err)
}
Preserve cancellation and deadline errors.
A practical context checklist
Use this checklist for Go backend code.
Function signatures
- Context is the first parameter.
- Context is not stored in long-lived structs.
- Context is not passed to pure helper functions unless needed.
- Nil context is never used.
Cancellation
- Long-running loops check
ctx.Done(). - Goroutines have a shutdown path.
- Worker lifetimes are tied to a parent context.
- Context cancellation is propagated to downstream calls.
Timeouts
- Outer request timeouts are set at the boundary.
- Sub-operation timeouts are smaller than the outer budget.
- Cancel functions are always called.
- Timeouts are not blindly stacked at every layer.
Values
- Context values are request-scoped.
- Keys use custom types, not plain strings.
- Dependencies are not stored in context.
- Optional parameters are not stored in context.
Errors
context.Canceledandcontext.DeadlineExceededare preserved.- Context errors are mapped correctly at API boundaries.
- Cause-aware cancellation is used only when the reason matters.
Tests
- Tests use
t.Context()where appropriate. - Timeout tests avoid slow real sleeps.
- Concurrent timeout behavior is tested with
testing/synctestwhen useful. - Goroutine leaks are checked by ensuring shutdown paths exist.
How to audit context usage in a Go codebase
Search for these patterns:
grep -R "context.Background()" .
grep -R "context.TODO()" .
grep -R "WithTimeout" .
grep -R "WithCancel" .
grep -R "WithValue" .
grep -R "type .* struct" .
Then ask:
- Is
context.Background()only used at top-level boundaries? - Are cancel functions always called?
- Are timeouts placed at sensible boundaries?
- Are context values really request-scoped?
- Are dependencies hidden in context values?
- Are goroutines stoppable?
- Are context errors preserved?
This is a good code review habit, because many context bugs are not syntax bugs — they are lifetime bugs that only surface under cancellation, load, or shutdown conditions.
My opinionated rules
These rules are boring, but they work.
Rule 1: Context is control flow
Use context to control cancellation, deadlines, and request metadata.
Do not use it to smuggle dependencies.
Rule 2: The caller owns the budget
A function should usually respect the context it receives.
Only create a shorter child timeout when the sub-operation needs a specific smaller budget.
Rule 3: Background belongs at the edge
Use context.Background() in main, tests, and top-level setup.
Do not use it inside service and repository methods to escape cancellation.
Rule 4: Values should be boring
Request ID, trace ID, user ID, and tenant ID belong in context. Database connections, loggers, config structs, and service clients do not — they are dependencies and should be passed explicitly.
Rule 5: Every goroutine needs a lifetime
If a goroutine starts, you should know exactly how it stops. Context is often the right answer, and if it is not context, there should be some other clear mechanism — a channel, a sync primitive, or an explicit signal.
Final thoughts
context.Context is not complicated because the API is large — the API is small. It is complicated because it represents lifetime, and lifetime is architecture. Every decision about where context flows, where it is derived, and where it stops is a decision about how your service handles failure, load, and shutdown.
A well-used context makes Go services easier to cancel, easier to shut down, easier to observe, and less likely to leak goroutines. A badly used context hides dependencies, discards deadlines, and makes code harder to reason about under pressure.
The practical takeaway is simple:
Pass context down.
Do not store it.
Do not replace explicit parameters with values.
Respect cancellation.
Use timeouts at boundaries.
Always call cancel.
That is Go context done right.
This article is part of the App Architecture in Production cluster, which covers code structure, data access, integration patterns, and testing architecture for production Go and Python systems.