The context Package
Reviewed & published by Brayan K
A context.Context carries cancellation signals, deadlines, and request-scoped values across API and goroutine boundaries, so a single timeout or cancel at the top of a request can cleanly unwind every goroutine working on it.
Part of the free Go course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- Create a root with context.Background
- Derive children with WithCancel, WithTimeout, WithValue
- React to cancellation via ctx.Done() and ctx.Err()
- Pass ctx as the first argument by convention
- Watch cancellation propagate down the context tree
- Always defer cancel() to avoid leaks
1️⃣ WithCancel, Done() and Err()
Every context tree starts at context.Background() — empty and never cancelled. context.WithCancel(parent) returns a child and a cancel function. A goroutine watches ctx.Done() — a channel that gets closed when you cancel — and ctx.Err() tells it why (context.Canceled or context.DeadlineExceeded).
package main
import (
"context"
"fmt"
)
func main() {
// context.Background() is the root context — empty, never cancelled.
// WithCancel derives a child PLUS a cancel function you call to stop it.
ctx, cancel := context.WithCancel(context.Background())
stopped := make(chan struct{})
go func() {
// The worker watches ctx.Done(): a channel that is CLOSED on cancel.
<-ctx.Done()
fmt.Println("worker: ctx.Done() fired, reason:", ctx.Err())
close(stopped)
}()
cancel() // tell the worker to stop
<-stopped // wait for it to acknowledge
fmt.Println("main: cancelled, ctx.Err() =", ctx.Err())
}2️⃣ WithTimeout and WithDeadline
WithTimeout(parent, d) gives you a context that cancels itself after duration d (WithDeadline takes an absolute time instead). Your task races its real work against ctx.Done() in a select; whichever fires first wins. Always defer cancel() — even on success — to release the underlying timer.
package main
import (
"context"
"fmt"
"time"
)
// doWork pretends to do something slow; it returns early if the context
// is cancelled (here: when its deadline passes).
func doWork(ctx context.Context) error {
select {
case <-time.After(200 * time.Millisecond): // the "real" work
return nil
case <-ctx.Done(): // deadline/cancel arrived first
return ctx.Err()
}
}
func main() {
// Give the work only 50ms. Because the work needs 200ms, the timeout
// ALWAYS wins, so this output is deterministic.
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel() // always cancel to release the timer, even on success
if err := doWork(ctx); err != nil {
fmt.Println("work failed:", err)
} else {
fmt.Println("work finished in time")
}
}3️⃣ WithValue and cancellation propagation
WithValue attaches request-scoped data — an ID, a trace, an auth identity — that travels with the request. Use an unexported key type to avoid collisions, and reserve it for data that crosses API boundaries (not ordinary parameters). The second half shows propagation: cancelling a parent cancels every child derived from it.
package main
import (
"context"
"fmt"
)
// A typed key avoids collisions in the context's value map.
type ctxKey string
const requestIDKey ctxKey = "requestID"
func handle(ctx context.Context) {
// Read a request-scoped value passed down through the context.
if id, ok := ctx.Value(requestIDKey).(string); ok {
fmt.Println("handling request:", id)
}
}
func main() {
// WithValue attaches request-scoped data (IDs, auth) — NOT optional
// parameters. Keep it to values that cross API boundaries.
ctx := context.WithValue(context.Background(), requestIDKey, "req-42")
handle(ctx)
// Cancellation PROPAGATES: cancelling a parent cancels all children.
parent, cancelParent := context.WithCancel(context.Background())
child, cancelChild := context.WithCancel(parent)
defer cancelChild()
cancelParent() // cancel the PARENT...
<-child.Done() // ...and the CHILD is cancelled too
fmt.Println("child cancelled because parent was, err:", child.Err())
}🎯 Your Turn
Build a context with a deadline and react to it. Fill in the two blanks marked ___, then run it.
package main
import (
"context"
"fmt"
"time"
)
func main() {
// 🎯 YOUR TURN — fill in the blanks marked ___
// 1) Make a context that cancels itself after 30 milliseconds
ctx, cancel := ___ // 👉 context.WithTimeout(context.Background(), 30*time.Millisecond)
defer cancel()
select {
case <-time.After(100 * time.Millisecond):
fmt.Println("work done")
// 2) Wake when the context is cancelled, and print its error
case <-___: // 👉 ctx.Done()
fmt.Println("cancelled:", ctx.Err())
}
// ✅ Expected output (30ms deadline beats 100ms work):
// cancelled: context deadline exceeded
}Common Errors (and the fix)
❌ Forgetting cancel() after WithTimeout — leaks the timer until the parent dies. go vet warns.
✅ defer cancel() on the very next line.
❌ Passing nil as a context — panics on the first ctx.Done()/Value.
✅ Use context.Background() or context.TODO() instead.
❌ Storing a context in a struct field and reusing it across requests.
✅ Pass ctx explicitly as the first argument of each call.
❌ Using WithValue for ordinary parameters — values are untyped and easy to misread.
✅ Pass real arguments; reserve context values for request-scoped data with a custom key type.
🧠 Quick Recall
ctx,_ := context.WithTimeout(bg, 0); <-ctx.Done(); ctx.Err()
context deadline exceeded — a zero (or already-passed) deadline cancels the context immediately, so Done() is already closed.
cancel child; does parent.Done() fire?
No. Cancellation flows parent → child, never the other way. Cancelling a child leaves the parent (and its siblings) running.
🎯 Mini-Challenge: Context-Aware Search
Implement search so it races its backend work against ctx.Done() with a select. With a 40ms deadline and 200ms work, it should return context deadline exceeded.
package main
import (
"context"
"fmt"
"time"
)
// search pretends to query a backend that takes 'cost' to respond. It must
// honour ctx: return ctx.Err() if the context is cancelled first.
func search(ctx context.Context, cost time.Duration) (string, error) {
// 🎯 MINI-CHALLENGE: implement this with a select over time.After(cost)
// and ctx.Done(). On success return "result", nil.
return "", nil
}
func main() {
// Caller allows 40ms. Backend needs 200ms, so search should return the
// context error.
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond)
defer cancel()
res, err := search(ctx, 200*time.Millisecond)
fmt.Println(res, err)
// ✅ Expected output:
// context deadline exceeded
}🎉 Lesson Complete!
- ✅ Every context tree starts at context.Background()
- ✅ WithCancel/WithTimeout/WithValue derive children; defer cancel() always
- ✅ React to cancellation with ctx.Done(); learn why with ctx.Err()
- ✅ Pass ctx as the first argument; never store it in a struct or pass nil
- ✅ Cancellation flows parent → child, unwinding a whole request at once
Practice quiz
What is the root context that every context tree starts from?
- context.Root()
- context.New()
- context.Background()
- context.Empty()
Answer: context.Background(). context.Background() is the empty, never-cancelled root from which child contexts are derived.
What does ctx.Done() return?
- A channel that is closed when the context is cancelled
- An error
- A bool
- The deadline time
Answer: A channel that is closed when the context is cancelled. Done() returns a channel that is closed on cancellation, so every goroutine selecting on it wakes at once.
What does ctx.Err() report after a WithTimeout deadline passes?
- context.Canceled
- nil
- io.EOF
- context.DeadlineExceeded
Answer: context.DeadlineExceeded. When the timeout trips, Err() returns context.DeadlineExceeded ('context deadline exceeded').
Why must you call cancel() even when using WithTimeout?
- To print the error
- To release the timer and resources tied to the context
- To restart the timer
- It is optional and has no effect
Answer: To release the timer and resources tied to the context. cancel() frees the internal timer and child-tracking goroutine; skipping it leaks them until the parent is cancelled.
If you cancel a PARENT context, what happens to its children?
- All children are cancelled too
- Nothing
- Only direct children
- They restart
Answer: All children are cancelled too. Cancellation propagates down the tree: cancelling a parent cancels every descendant context.
If you cancel a CHILD context, does the parent get cancelled?
- Yes
- Only with WithTimeout
- No — cancellation only flows parent to child
- Only the root
Answer: No — cancellation only flows parent to child. Cancellation flows parent to child, never the other way; cancelling a child leaves the parent running.
By convention, where does the context argument go in a function signature?
- Last
- First, named ctx
- In a struct field
- It is a global
Answer: First, named ctx. The convention is ctx context.Context as the first parameter; never store it in a struct or pass nil.
What should you pass instead of a nil context?
- An empty struct
- context.Nil()
- A closed channel
- context.Background() or context.TODO()
Answer: context.Background() or context.TODO(). Passing nil panics on ctx.Done()/Value(); use context.Background() or context.TODO() instead.
What kind of data belongs in context.WithValue?
- Optional function parameters
- Request-scoped data crossing API boundaries (IDs, auth)
- Large config objects
- Database connections
Answer: Request-scoped data crossing API boundaries (IDs, auth). WithValue is for request-scoped data like a request ID or auth identity — not ordinary parameters or dependencies.
How does a long task respond to cancellation?
- It cannot
- Sleep until the deadline
- Select on ctx.Done() or check ctx.Err() and return early
- Call cancel() itself
Answer: Select on ctx.Done() or check ctx.Err() and return early. Tasks select on ctx.Done() alongside real work, or check ctx.Err() each iteration, returning early when cancelled.
Continue this course
- Previous: sync: WaitGroup, Mutex, Once
- Next: Worker Pools & Pipelines — Bounded concurrency with a jobs channel, N workers, and a results channel
Frequently asked questions
Where should the context argument go in a function signature?
By convention ctx context.Context is always the FIRST parameter, named ctx: func Fetch(ctx context.Context, url string) (...). Never store a context in a struct field and never pass nil — if you genuinely have no context yet, pass context.TODO() so the intent is explicit and tools can flag it.
What's the difference between Background and TODO?
Both return an empty root context that is never cancelled. Use context.Background() at the top of your program — in main, in tests, and as the root of an incoming request. Use context.TODO() as a placeholder when you haven't decided which context to use yet or are mid-refactor; it documents 'a real context should be wired here later'.
Why do I have to call cancel() even when I use WithTimeout?
WithCancel, WithTimeout, and WithDeadline all return a cancel function, and you must call it — defer cancel() right after — even if the operation finishes early. Cancel releases the resources (an internal timer and the goroutine tracking children) associated with the context. Skipping it leaks them until the parent is cancelled, and go vet warns about it.
How does cancellation propagate to child contexts?
Contexts form a tree: each WithCancel/WithTimeout/WithValue derives a child from a parent. Cancelling a parent (or its deadline passing) automatically cancels every descendant — their Done() channels close too. Cancelling a child, however, does NOT affect the parent. This is what lets one timeout at the top of a request unwind every goroutine working on it.
What should I put in context.WithValue?
Only request-scoped data that crosses API boundaries — a request ID, a trace span, or auth identity. Do NOT use it to pass optional function parameters or dependencies; those belong in explicit arguments or struct fields. Values are untyped (any), so always use an unexported custom key type to avoid collisions and assert the type when you read it back.
How do I actually respond to cancellation inside a long task?
Select on ctx.Done() alongside your real work, or check ctx.Err() at the top of each loop iteration and return early when it's non-nil. Any blocking call you make (database query, HTTP request) should also take the ctx so the standard library can abort it for you. A task that ignores ctx.Done() will keep running after a timeout, defeating the whole point.