1. Why context needs rules
When you first see context.Context, it can feel like “some little bundle we carry everywhere because that’s the convention”. And there’s some truth to that: in Go, many conventions exist because people have already learned painful lessons. Context is exactly that kind of story: using it incorrectly rarely breaks a program immediately, but it breaks it really well later, under load, and at the worst possible moment (that is, always).
A good mental model is this: context is an accompanying packet of documents for a single operation. This “packet” contains information about the operation’s lifetime (timeouts/deadlines), the ability to cancel, and a bit of metadata. But it’s not “a backpack where we throw everything we don’t want to pass as parameters”.
If you mix up these roles, you’ll end up with code that works in a demo, but suddenly “sticks” in prod, loses cancellation, inherits someone else’s timeouts, and generally lives like a cat: on its own.
2. Rule #1: don’t store business data in context
Beginners stumble on this rule most often, because it hits a very human desire: “oh, I need to pass one more parameter… let’s just shove it into context so we don’t have to change signatures”. Developers call this “quickly, just for five minutes”, and support calls it “why did everything break three months later”.
What “business data” means here
Business data is everything related to your application’s domain: userID, taskID, user role, task filtering parameters, selected project, task contents, access rules, pricing configuration, etc. These are the data without which the meaning of an operation changes, not just its “packaging”.
Operation metadata is what helps execute or track an operation but doesn’t change its business meaning. Example: request_id for logs, a deadline, a flag indicating “who initiated cancellation”.
To make it easier to tell them apart, keep this table in mind:
| Question | If the answer is “yes”, it’s business data | If the answer is “no”, it might be metadata |
|---|---|---|
| Will the operation result change if the value is different? | yes | no |
| Is this part of the domain model (user, task, project)? | yes | no |
| Should this be explicitly visible in the signature to understand the contract? | yes | not always |
| Does this look like a “service label” for logs/tracing? | no | yes |
Typical anti-pattern: “let’s carry userID through ctx”
Let’s imagine our learning project—a task manager (todo). We want to create a task. A bad idea looks like this: don’t pass the user as a parameter; “pull” it from context.
package app
import "context"
func CreateTask(ctx context.Context, title string) error {
userID := ctx.Value("user_id").(int) // panic if missing or type is wrong
_ = userID
_ = title
return nil
}
The problem here isn’t even that a string key is used (that’s also bad); the real issue is that the function contract is hidden. From the signature it looks like: “create a task by title”, but in reality it’s: “create a task for a user who is hidden somewhere”.
Then you call CreateTask(ctx, "buy milk"), and it crashes because you forgot to put user_id into the context. Or, more fun, it uses the wrong user because the context is not what you thought. It’s like signing documents with invisible ink: formally there is a signature, but finding it is a whole separate quest.
The normal way: business data as explicit parameters
The correct approach is to pass business values explicitly. Yes, signatures get longer. But the code becomes clearer, and bugs become more visible.
package app
import "context"
func CreateTask(ctx context.Context, userID int, title string) error {
_ = ctx // context is about lifetime/cancellation
_ = userID // business data
_ = title
return nil
}
This is not a “formality”. It’s documenting the contract at the compiler level: it’s impossible to call the function without specifying the user.
If the number of parameters grows, we usually group them into a request struct (in Go terms: “a small struct instead of ten arguments”):
package app
import "context"
type CreateTaskRequest struct {
UserID int
Title string
}
func CreateTask(ctx context.Context, req CreateTaskRequest) error {
_ = ctx
_ = req
return nil
}
Now a human reads your code and doesn’t have to guess what’s inside the context. They can see it in the types.
Why storing business data in context is dangerous
The problem is that context conceptually belongs to an operation, while business data belongs to the model. An operation can be reused, canceled, forked into multiple goroutines, constrained by a deadline—while business data must remain deterministic and explicit.
Another practical reason: context is easy to “zero out” by mistake. It takes just one layer doing context.Background() instead of the incoming ctx, and your “hidden parameters” disappear. Then someone writes .Value(...).(int) somewhere—and hello, panic. That’s why “hiding meaning” in context is like keeping your apartment keys under the doormat: yes, it’s convenient until someone finds them (or until you forget which doormat).
3. Rule #2: don’t keep context in a struct for long
At first glance it seems logical: “I have a service/client/worker; let it store ctx inside so I don’t have to pass it into every method”. But in Go this is considered bad practice precisely because it breaks the understanding of lifetimes and makes the API murky. Official recommendations usually emphasize that storing context in a struct mixes different lifetime “scopes” and makes it harder to set deadlines/cancellation for a specific call.
What bad design looks like
Here’s an example of “how not to do it”:
package app
import "context"
type TaskServiceBad struct {
ctx context.Context
}
func NewTaskServiceBad(ctx context.Context) *TaskServiceBad {
return &TaskServiceBad{ctx: ctx}
}
func (s *TaskServiceBad) Create(title string) error {
_ = s.ctx // one shared context for all calls
_ = title
return nil
}
From the outside it looks convenient: we don’t pass ctx every time. But now a bunch of unpleasant questions appear (and this isn’t philosophy—these are real future bugs).
What does the ctx passed into NewTaskServiceBad relate to? Is it the context of service initialization? The context of “the whole program”? The context of a single HTTP request? What happens if the service lives for a year, but the context was created for 2 seconds? Spoiler: the service turns into a pumpkin, and all methods start immediately returning context deadline exceeded, because ctx is already done.
Also, such a service can’t be used correctly in scenarios where each call has its own deadline or its own cancellation. In a real system, “one shared ctx for everything” is almost never what you want.
Good design: ctx comes with every call
The normal Go approach is to make ctx the first argument of a method/function:
package app
import "context"
type TaskService struct{}
func NewTaskService() *TaskService {
return &TaskService{}
}
func (s *TaskService) Create(ctx context.Context, title string) error {
_ = ctx
_ = title
return nil
}
Now everything is transparent: the lifetime is defined at the “Create” operation level. Want a 200ms timeout—set it up top and pass it down. Want to cancel—cancel up top. The service doesn’t do “magic” and doesn’t keep someone else’s lifetime in its pocket.
“But sometimes there is context in a struct!”—when is it acceptable
Yes, in the standard library you’ve seen context inside http.Request. This is done for backward compatibility: the API existed before context appeared, and context support had to be added without breaking signatures.
The key point: http.Request is an object that already represents a single operation (one request). That is, its “lifetime” is limited to the request lifetime. So storing ctx inside it is logical and doesn’t turn into “an eternal context for all occasions”.
But in your code, Service, Repository, Client, and other objects typically live longer than a single operation. So storing ctx there “for a long time” is almost always a mistake.
4. Passing ctx through application layers
Now we’ll assemble the familiar layered picture: an HTTP handler calls a service, the service calls storage. Our goal is for the context to travel the same path, without being replaced with Background() and without trying to hide business data.
Let’s imagine minimal interfaces in a todo app.
package app
import "context"
type TaskRepo interface {
Create(ctx context.Context, userID int, title string) (int, error)
}
type TaskService struct {
repo TaskRepo
}
func NewTaskService(repo TaskRepo) *TaskService {
return &TaskService{repo: repo}
}
Notice: TaskService stores a dependency (the repository), but it does not store context. This is “allowed storage”: dependencies live as long as the service does. Context does not.
Now the service method:
package app
import "context"
func (s *TaskService) CreateTask(ctx context.Context, userID int, title string) (int, error) {
if err := ctx.Err(); err != nil {
return 0, err
}
return s.repo.Create(ctx, userID, title)
}
Here we do an early ctx.Err() check. This isn’t strictly required in every function, but it’s a good guard clause: if we’re already canceled, don’t start work.
The HTTP layer (very simplified, without full JSON decoding):
package httpapi
import (
"net/http"
"strconv"
)
func (h *Handler) createTask(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
userID, _ := strconv.Atoi(r.URL.Query().Get("user"))
title := r.URL.Query().Get("title")
_, _ = h.svc.CreateTask(ctx, userID, title)
w.WriteHeader(http.StatusCreated)
}
The key idea here: userID and title are not from context. They are part of the request by meaning. We take context from r.Context() and pass it for cancellation/timeouts.
If later you want to set a timeout at the boundary, you’ll do it in the handler (or middleware), but still based on r.Context(), not on Background().
5. Practical signals and acceptable data in context
How to tell you’ve broken the rules
In this section, it’s better to give you not “commandments carved in stone”, but human signs that you’re heading toward pain. Violations usually look the same: “unexplainable magic” and strange dependencies between calls start to appear.
If you see code where a function accepts ctx, but inside creates context.Background() “because it’s easier”, it almost always means: you just cut the cancellation/timeout wires, and the upper level no longer controls the lifetime of the lower level.
package storage
import "context"
func (r *Repo) Create(ctx context.Context, userID int, title string) error {
_ = ctx
ctx = context.Background() // bad: cancellation and deadline are lost
_ = userID
_ = title
return nil
}
If you see a struct that has a field ctx context.Context, and that struct lives for a long time (service, client, repo), it almost always means: you’ll end up with a shared “eternal context” that either expires too early or gets canceled by the wrong party. This is exactly the situation usually described as “context in structs leads to confusion”.
And if you see .Value(...) used to extract business data, and it’s not logging/tracing, then you’re most likely hiding the meaning of the operation in a place where nobody expects to find it.
What you can store in context
It’s important not to draw the wrong conclusion: “so context can’t be used for data at all”. It can—but only for operation metadata. A classic example: request_id, which is needed to correlate logs across layers for one request.
But even here we keep discipline: this data must not change the business result. It should help with diagnostics, tracing, logging, and sometimes authorization at the boundary (but not as a “secret parameter transport”).
Also remember that ctx.Value returns any, and any extraction requires care. That’s why people usually create helper functions so they don’t smear “value extraction” across the whole codebase. (We’ll cover this in detail, properly, in a neighboring topic about WithValue.)
6. Common mistakes
Mistake #1: “Let’s temporarily put userID into context, then we’ll refactor it”.
This is almost never “temporary”; it’s “forever”, because as soon as the code starts working, motivation to refactor drops to the battery level of an old remote. After a while you discover that half the functions silently depend on a value being present in context, and any new entry point (CLI, test, another handler) starts crashing or behaving strangely.
Mistake #2: “The service needs context, so let’s put it into a struct field”.
The problem won’t show up immediately: at first everything is fine, then a second use-case for the service appears (different timeout, different cancellation), and you realize the service is “stuck” in one lifetime. This makes the API non-obvious and prevents setting deadlines for specific calls. That’s why storing context in a struct is considered a source of confusion.
Mistake #3: Replacing the incoming ctx with context.Background() “inside, just in case”.
This breaks the “cancellation chain”: the upper level can cancel a request, but the lower level keeps running and consuming resources. Under load, this leads to accumulating stuck operations and a very unpleasant “everything is slow, but there are no errors” effect.
Mistake #4: Mixing business data and metadata in context.
Even if you store request_id in context for logs, you shouldn’t also put “current task”, “user permissions”, or “filter parameters” there. This breaks contract transparency: from the signature it’s unclear what the result depends on. In the end you lose Go code’s main advantage: readability through explicit types and parameters.
Mistake #5: Extracting a value from context via .Value(...).(T) without checking.
In such code, the “missing value error” turns into a panic, and often not where you forgot to set the value, but deep in the stack. Even if it’s metadata, extraction must be safe (via a type check), otherwise you get a brittle system that crashes from one incorrect call.
GO TO FULL VERSION