The first time I saw
context.Context, I thought:"Oh, a way to pass timeouts. Nice."
I was wrong.
Embarrassingly wrong.
The kind of wrong that quietly leaks goroutines for 18 months before someone notices.
Then I thought:
"Okay, it's for cancellation."
Still wrong.
Closer.
But wrong.
The truth is stranger—and far more powerful.
context.Context is:
- The most elegant solution to a problem you didn't know you had
- The most common source of bugs you don't realize you're writing
- The backbone of modern Go concurrency
Let's fix that.
Forever.
Part 1: The Lie They Tell You About Context
Most tutorials teach Context like this:
"Context is a way to set deadlines and cancel operations."
Example:
ctx, cancel := context.WithTimeout(
context.Background(),
5*time.Second,
)
defer cancel()
result, err := doSomething(ctx)
✅ Correct
✅ Useful
❌ Only 5% of the story
The Real Definition
Context is an immutable value that flows through your call graph, carrying cancellation signals, deadlines, and request-scoped data from root to leaf.
It never flows backward.
It never flows sideways.
Read that twice.
Everything else follows from this.
Part 2: The One Rule That Explains Everything
Context flows DOWN.
Never up.
Never sideways.
Request
│
▼
Handler
│
▼
Service
│
▼
Repository
│
▼
Database
The same context travels through the entire operation:
ctx ──────────────────────────────►
Creating Child Contexts
parent := ctx
child, cancel :=
context.WithCancel(parent)
grandchild, _ :=
context.WithTimeout(child, time.Second)
Perfectly legal.
What You Cannot Do
parent := context.WithCancel(child)
❌ Doesn't exist.
merged := context.Merge(ctx1, ctx2)
❌ Also doesn't exist.
Why?
Because a context represents:
- One request
- One operation
- One unit of work
When a parent dies:
Parent
├── Child A
├── Child B
└── Child C
Everything below it dies too.
But killing a child never affects the parent.
That predictability is the entire point.
Part 3: The Four Context Superpowers
⚡ Superpower 1 — Cancellation
The one everyone knows.
func SearchProducts(
ctx context.Context,
query string,
) ([]Product, error) {
searchCtx, cancel :=
context.WithTimeout(ctx, time.Second)
defer cancel()
return db.Query(
searchCtx,
"SELECT * FROM products ...",
)
}
When the timeout expires:
- Database receives cancellation
- Query stops
- Resources get cleaned up
No zombies.
No hanging connections.
The Secret Nobody Mentions
Cancellation is cooperative.
Context doesn't magically stop your code.
Your code must cooperate.
Bad
func SlowOperation(ctx context.Context) error {
for i := 0; i < 1_000_000; i++ {
doExpensiveThing()
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
return nil
}
This ignores cancellation until the end.
Good
func SlowOperation(ctx context.Context) error {
for i := 0; i < 1_000_000; i++ {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
doExpensiveThing()
}
return nil
}
Check often.
Exit early.
⚡ Superpower 2 — Value Propagation
Context can carry request-scoped metadata.
ctx = context.WithValue(
ctx,
userIDKey,
"alice-123",
)
Later:
userID := ctx.Value(userIDKey)
Danger Zone
This compiles:
userID := ctx.Value(userIDKey).(string)
And this also compiles:
userID := ctx.Value(userIDKey).(int)
The second one explodes at runtime.
Use Context Values For...
✅ Good
- Request IDs
- User IDs
- Tenant IDs
- Tracing
- Logging metadata
❌ Bad
- Database connections
- Service dependencies
- Business logic inputs
- Optional configuration
Golden Rule
If business logic requires it, pass it explicitly.
If it's plumbing, context is acceptable.
⚡ Superpower 3 — Deadline Propagation
Parent deadlines always win.
ctx, cancel :=
context.WithTimeout(
context.Background(),
5*time.Second,
)
defer cancel()
Now create a child:
childCtx, childCancel :=
context.WithTimeout(
ctx,
10*time.Second,
)
defer childCancel()
Guess the deadline?
Not 10 seconds.
Still 5.
Rule
Context deadlines are minimal.
The strictest deadline wins.
Always.
This prevents downstream code from extending upstream limits.
⚡ Superpower 4 — Goroutine Lifecycle Management
This is where senior engineers separate themselves.
Bad
func startWorker() {
go func() {
for {
time.Sleep(time.Second)
doWork()
}
}()
}
This worker lives forever.
Or until production starts smoking.
Good
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()
}
}
}()
}
Usage:
ctx, cancel :=
context.WithCancel(
context.Background(),
)
startWorker(ctx)
cancel()
Worker exits gracefully.
Rule
Every goroutine without a context is a potential memory leak.
Every.
Single.
One.
Part 4: The Context Crimes
☠️ Crime 1 — Storing Context in Structs
type Repository struct {
ctx context.Context
db *sql.DB
}
Never do this.
Why?
- Context becomes stale
- Request ownership becomes unclear
- Testing becomes painful
Correct
type Repository struct {
db *sql.DB
}
func (r *Repository) GetUser(
ctx context.Context,
id int,
) (*User, error) {
...
}
☠️ Crime 2 — Ignoring Cancellation
Bad:
http.Get(url)
This uses a background context.
Your timeout is ignored.
Good:
req, _ :=
http.NewRequestWithContext(
ctx,
"GET",
url,
nil,
)
resp, err :=
http.DefaultClient.Do(req)
Now cancellation actually works.
☠️ Crime 3 — Background Work Using Request Context
go func() {
expensiveOperation(r.Context())
}()
Looks fine.
It's not.
Request ends.
Context cancels.
Background work dies.
Fix
bgCtx := context.WithoutCancel(
r.Context(),
)
Now it survives the request.
☠️ Crime 4 — Nil Contexts
if ctx == nil {
ctx = context.Background()
}
Don't.
A nil context is a caller bug.
Make it fail loudly.
Part 5: Patterns You'll Actually Use
🥪 The Timeout Sandwich
apiCtx, cancel :=
context.WithTimeout(
ctx,
2*time.Second,
)
defer cancel()
Create short-lived child contexts around external dependencies.
Always.
🆔 Request ID Propagation
Middleware:
ctx := WithRequestID(
r.Context(),
requestID,
)
Handler:
log.Printf(
"[%s] Processing request",
GetRequestID(ctx),
)
Instant traceability.
🔀 Fan-Out With Early Cancellation
Launch multiple queries:
search
recommendation
inventory
Collect results.
Exit immediately on failure.
All goroutines respect the same context.
One cancellation signal controls everything.
Beautiful.
Part 6: The Context Decision Tree
Do you already have a context?
│
┌────┴────┐
│ │
YES NO
│ │
Pass it ▼
down Background job?
│
┌──────┴──────┐
│ │
YES NO
│ │
Background() TODO()
The Rule That Prevents Leaks
Every one of these:
context.WithCancel(...)
context.WithTimeout(...)
context.WithDeadline(...)
Must have:
defer cancel()
Immediately.
Not later.
Immediately.
🔍 This Week's Challenge
Audit your codebase.
Find:
- [ ] Goroutines without contexts
- [ ] Context stored in structs
- [ ] Raw
http.Get() - [ ] Missing
defer cancel() - [ ] Loops ignoring
ctx.Done()
You'll find bugs.
Guaranteed.
The Mental Model
Remember only this:
Context flows down.
Cancellation flows up.
Values are for plumbing.
Business data is explicit.
Everything else is implementation detail.
One-Page Cheat Sheet
✅ Do This
func DoWork(
ctx context.Context,
arg string,
) error {
return doSubWork(ctx, arg)
}
ctx, cancel :=
context.WithTimeout(
parent,
5*time.Second,
)
defer cancel()
func handler(
w http.ResponseWriter,
r *http.Request,
) {
ctx := r.Context()
}
❌ Don't Do This
type Worker struct {
ctx context.Context
}
ctx, cancel :=
context.WithTimeout(
parent,
5*time.Second,
)
// forgot cancel()
http.Get(url)
Final Thought
Most developers think Context is about timeouts.
It's not.
Timeouts are merely a feature.
The real purpose of context.Context is to give an entire tree of operations a shared lifecycle.
Once that clicks:
- Timeouts make sense
- Cancellation makes sense
- Request scoping makes sense
- Goroutine management makes sense
And suddenly those mysterious 73 lines in the standard library become one of the most elegant designs in Go.
Context flows down.
Never forget it.