DEV Community

Cover image for The 5 Ways Go Developers Misuse context.Context
Gabriel Anhaia
Gabriel Anhaia

Posted on

The 5 Ways Go Developers Misuse context.Context

๐Ÿ“š This post pairs with two books I've written on Go. Book 1: The Complete Guide to Go Programming. Book 2: Hexagonal Architecture in Go. Or both together as the Thinking in Go collection: Kindle / Paperback. Short blurbs at the bottom.

Series - Thinking in Go

A ctx context.Context parameter that the function body never reads from is worse than no ctx at all. It's a lie about cancellation. Every caller who passes a context expecting the callee to honor it is going to be surprised the first time cancellation silently fails.

That's the thesis of this post. context misuse isn't a style nit. It's a correctness bug that passes go vet, passes code review, and ships to production in a huge percentage of real Go services.

We're going to look at five specific mistakes, in order of how painful they are to debug. Then we'll look at what correct usage actually looks like, including a small but excellent example that shipped in Go 1.26. By the end you'll have a one-sentence test you can apply to any function in code review.

The contract you didn't know you signed

When you accept a ctx context.Context parameter, you're making a promise. The promise is: "If someone cancels this context, I will stop what I'm doing and return, in a bounded amount of time."

That promise is invisible. There's no compiler check. There's no go vet lint. The linter will tell you to pass ctx to every function that takes one, but it won't tell you whether the function actually uses it. Which means the ecosystem is full of functions that take ctx so the linter stops complaining, and then do nothing with it.

Every mistake in this post is a different way to break that promise.

Mistake 1: accepting ctx and never reading from it

The most common one:

// looks fine. isn't.
func (s *ReportService) Generate(ctx context.Context, userID string) (*Report, error) {
    rows := s.queryAllOrders(userID)

    report := newReport()
    for _, r := range rows {
        report.Add(processRow(r))
    }
    return report.Build(), nil
}
Enter fullscreen mode Exit fullscreen mode

ctx is in the signature. Nothing inside the function ever touches it. If the caller cancels, this function runs to completion anyway, burning CPU and holding the goroutine.

The fix isn't "add one ctx.Done() check at the top." That catches cancellation that already happened before the call started, which is the least useful moment to check. The fix is to pass ctx down to every operation that can block, and to check it inside the loop if the loop is long:

func (s *ReportService) Generate(ctx context.Context, userID string) (*Report, error) {
    rows, err := s.queryAllOrders(ctx, userID) // pass ctx to the DB call
    if err != nil {
        return nil, err
    }

    report := newReport()
    for i, r := range rows {
        // cheap periodic check in a potentially long loop
        if i%1000 == 0 {
            if err := ctx.Err(); err != nil {
                return nil, err
            }
        }
        report.Add(processRow(r))
    }
    return report.Build(), nil
}
Enter fullscreen mode Exit fullscreen mode

Two rules doing the work here. Anything that blocks on I/O or takes non-trivial CPU gets ctx passed through. Anything that loops for a long time checks ctx.Err() on a reasonable interval (every 1000 iterations, every batch, whatever). You do not need to check it on every single iteration. You need to check it often enough that cancellation lands within your cancellation budget.

Mistake 2: storing ctx in a struct

The docs say this explicitly: "Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it." People still do it. The code looks nice, it saves typing, what could go wrong?

// the tempting version
type Worker struct {
    ctx  context.Context
    db   *sql.DB
    logs chan LogEntry
}

func NewWorker(ctx context.Context, db *sql.DB) *Worker {
    return &Worker{ctx: ctx, db: db, logs: make(chan LogEntry, 100)}
}

func (w *Worker) Run() {
    for entry := range w.logs {
        w.db.ExecContext(w.ctx, "INSERT INTO logs ...", entry)
    }
}
Enter fullscreen mode Exit fullscreen mode

The problem is a lifetime mismatch. Whose context is that? The HTTP request's? The application's? The test's? If it's the request's, the worker dies as soon as one request finishes. If it's the application's, you've accidentally tied every database call to a context that never cancels, defeating the point.

The fix is to pass ctx per call, not per instance:

type Worker struct {
    db   *sql.DB
    logs chan LogEntry
}

func (w *Worker) Run(ctx context.Context) {
    for {
        select {
        case entry, ok := <-w.logs:
            if !ok {
                return
            }
            // each call gets the current, relevant context
            _, _ = w.db.ExecContext(ctx, "INSERT INTO logs ...", entry)
        case <-ctx.Done():
            return
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Now Run takes the context, the caller controls when it's cancelled, and the struct doesn't lie about which operation the context is supposed to cover.

The only place context.Context legitimately belongs inside a struct is in the standard library's own request types (http.Request has one because it's specifically a request), and you almost never want to copy that pattern in your own code.

Mistake 3: context.Background() deep inside business logic

This one is the silent leak. You're five calls deep into a service method, you need to make an outbound call, and the original ctx isn't available because someone didn't thread it through. So you write:

func (s *PaymentService) chargeCard(card Card, cents int) error {
    // just need to call the gateway, right?
    ctx := context.Background()
    resp, err := s.gateway.Charge(ctx, card, cents)
    // ...
}
Enter fullscreen mode Exit fullscreen mode

And now the cancellation chain is severed. The HTTP handler that called this service thinks cancellation propagates. It doesn't. If the client disconnects mid-request, the charge still goes through, because context.Background() never cancels.

This is the worst category of context bug because it's often invisible at the point of failure. The customer cancels the request. Your handler returns with a cancel error. Your logs show a clean cancellation. The charge still happened. Welcome to a support ticket.

The fix is to pass ctx through, every single call, all the way down:

func (s *PaymentService) chargeCard(ctx context.Context, card Card, cents int) error {
    resp, err := s.gateway.Charge(ctx, card, cents)
    if err != nil {
        return err
    }
    // ...
    return nil
}
Enter fullscreen mode Exit fullscreen mode

context.Background() has exactly two correct use sites:

  1. The main() function, at the root of your program.
  2. The TestMain, setup, or teardown of a test.

If you're calling context.Background() anywhere else, you're starting a new lifetime at a place where you should be continuing an existing one. That's almost always a bug.

context.TODO() is for code you'll fix later. Grep your codebase for it. You have some. You always have some.

Mistake 4: context.Value for required parameters

This one is subtle because the code works. Testing seems fine. Then someone refactors and a runtime panic appears because a value that the function needs isn't in the context anymore.

// looks elegant. isn't.
type userIDKey struct{}

func WithUserID(ctx context.Context, id string) context.Context {
    return context.WithValue(ctx, userIDKey{}, id)
}

func GetUserID(ctx context.Context) string {
    return ctx.Value(userIDKey{}).(string) // panics if not set
}

func (s *Service) Greet(ctx context.Context) string {
    return "hello, " + GetUserID(ctx)
}
Enter fullscreen mode Exit fullscreen mode

Greet has a required parameter (the user ID) that's hidden in the context. The type system doesn't know it's required. You can't tell from the signature. Renaming or moving code can silently drop the value, and the first sign will be a panic in production.

context.Value is for request-scoped metadata that's truly ambient: a request ID, a trace ID, the authenticated user's principal for audit logging. Things that every middleware and every handler might want, but none of them must have. If the function needs the value to do its job, make it an explicit parameter:

func (s *Service) Greet(ctx context.Context, userID string) string {
    return "hello, " + userID
}
Enter fullscreen mode Exit fullscreen mode

The shorter test for whether context.Value is right: "If this value weren't in the context, would the compiler let me call the function anyway?" If yes, and the function needs the value, you've turned a compile error into a runtime panic.

Mistake 5: not deriving a child context for outbound calls

Your HTTP handler gets a 30-second deadline from the client. You call a downstream service that's supposed to respond in 200ms. You pass the handler's ctx straight through.

func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
    user, err := h.upstream.Fetch(r.Context(), r.PathValue("id"))
    if err != nil { /* ... */ }
    json.NewEncoder(w).Encode(user)
}
Enter fullscreen mode Exit fullscreen mode

Works fine in the happy case. The problem shows up when the downstream is slow. The downstream call inherits your full 30-second deadline and spends up to 30 seconds hanging, which means your handler spends up to 30 seconds hanging, which means your server's connection pool spends up to 30 seconds exhausted.

You want a tighter deadline on outbound calls than on inbound requests. Two lines:

func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
    defer cancel()

    user, err := h.upstream.Fetch(ctx, r.PathValue("id"))
    if err != nil { /* ... */ }
    json.NewEncoder(w).Encode(user)
}
Enter fullscreen mode Exit fullscreen mode

context.WithTimeout returns a child context that cancels when either the parent cancels or 500ms elapses, whichever comes first. Now the downstream call has its own budget, your handler is bounded, and your connection pool doesn't fill up when an upstream goes slow.

Always call the returned cancel. The linter will yell at you if you don't, and the linter is right. Leaking a cancel function leaks the timer.

What correct context usage looks like in the stdlib

One of the clearest correctness examples shipped in the Go standard library in 1.26: signal.NotifyContext now cancels with a CancelCauseFunc carrying the signal that caused the cancellation.

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

// run your server
server := &http.Server{Addr: ":8080"}
go server.ListenAndServe()

<-ctx.Done()
// in Go 1.26, context.Cause(ctx) returns the signal that fired
log.Printf("shutting down: %v", context.Cause(ctx))

shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = server.Shutdown(shutdownCtx)
Enter fullscreen mode Exit fullscreen mode

A few things worth noticing. NotifyContext is exactly what context.Background() is legitimately for: the root of the program, turned into a cancellable root. context.Cause(ctx) tells you why the context was cancelled, which shows up in logs and metrics as the actual signal (SIGTERM vs SIGINT) instead of a generic cancel. And the shutdown path reaches for a fresh context.WithTimeout because once the main context is cancelled, you still need a bounded window to actually shut down cleanly.

Copy this shape. It's the template for program startup and shutdown, and most Go services get it wrong.

The one-sentence test

Every function in your codebase that takes a ctx should pass this test:

If someone cancels this context right now, how many seconds until this function returns?

A specific number bounded by the function's own behavior means the function honors the contract. "Depends on what I'm calling" or "I don't know" means the function is passing the buck, which is fine as long as the things it calls honor the contract too. "It won't return" or "whenever the work happens to finish" means the function is lying about cancellation.

Run this test in code review, run it on your own PRs before you open them, and run it especially hard on any function with a for loop. Long loops are where cancellation goes to hide.

Correct end-to-end: handler โ†’ service โ†’ repository

To make it concrete, here's what correct context propagation looks like across three layers:

// handler: the entry point. derives a tight deadline from the request context.
func (h *OrderHandler) Create(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
    defer cancel()

    var req CreateOrderRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    order, err := h.svc.Create(ctx, req)
    if err != nil {
        writeError(w, err)
        return
    }
    _ = json.NewEncoder(w).Encode(order)
}

// service: passes ctx to every call that blocks. no storing, no background.
func (s *OrderService) Create(ctx context.Context, req CreateOrderRequest) (*Order, error) {
    if err := s.validate(req); err != nil {
        return nil, err
    }
    order := newOrder(req)
    if err := s.repo.Save(ctx, order); err != nil {
        return nil, err
    }
    if err := s.events.Publish(ctx, OrderCreated{ID: order.ID}); err != nil {
        // log but don't fail the request
    }
    return order, nil
}

// repository: passes ctx to ExecContext / QueryContext, always.
func (r *OrderRepo) Save(ctx context.Context, o *Order) error {
    _, err := r.db.ExecContext(ctx,
        `INSERT INTO orders (id, user_id, cents) VALUES ($1, $2, $3)`,
        o.ID, o.UserID, o.Cents,
    )
    return err
}
Enter fullscreen mode Exit fullscreen mode

Every layer honors the contract. Every layer answers "how long until I return?" with "at most 2 seconds, whichever of the passed context or the layer's own I/O bounds fires first." Nothing stored in structs, no detours through context.Background, no required values smuggled through Value.

Next step

Pick one service in your codebase. Grep for context.Background() and context.TODO(). Every hit that isn't in main or TestMain is a candidate. Fix them. Measure how many you found.

Then grep for ctx context.Context and check each function. Does the body actually read from ctx or pass it to something that does? If not, that's a broken promise.

The cancellation your service is already supposed to support is probably already failing. Go make it not.

Question for the comments: what's the weirdest context misuse you've found in a codebase, yours or someone else's? Bonus points if it shipped.


The books

๐Ÿ“– The Complete Guide to Go Programming โ€” Book 1. The language from syntax to the standard library, including the parts of context, sync, and goroutines that tutorials gloss over.

๐Ÿ“– Hexagonal Architecture in Go โ€” Book 2. How to structure a Go service so that context propagation is trivial: one direction, one type, one rule. 22 chapters + a companion repo.

๐Ÿ“š Or the full collection: Thinking in Go on Amazon as Kindle or Paperback.

This concludes the **Go in Production* mini-series on pprof, goroutine leaks, and context. If you read all three, you've got the observability and correctness baseline most Go services lack.*

Top comments (0)