Context Package Intermediate¶
Introduction¶
The context package carries deadlines, cancellation signals, and request-scoped values across API boundaries and between goroutines. Every long-running or I/O-bound operation in Go should accept a context.Context as its first parameter. This is how you implement graceful shutdown, HTTP request timeouts, database query cancellation, and more.
Context is Go's answer to the question: "How do I tell downstream work to stop?"
Syntax & Usage¶
Root Contexts¶
Every context tree starts from a root. There are two:
ctx := context.Background() // default root — use in main(), init(), tests
ctx := context.TODO() // placeholder when unsure which context to use
Background() and TODO() are identical in behavior — both return a non-nil, empty context that is never canceled. The difference is intent: TODO() signals to readers that the correct context needs to be wired in later.
context.WithCancel¶
Returns a derived context and a cancel function. Calling cancel() signals all goroutines watching this context to stop.
func monitorServer(ctx context.Context) {
ctx, cancel := context.WithCancel(ctx)
defer cancel() // always call cancel to release resources
go watchHealth(ctx)
go watchMetrics(ctx)
<-ctx.Done() // blocks until parent or this context is canceled
}
func watchHealth(ctx context.Context) {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
fmt.Println("health check stopped:", ctx.Err())
return
case <-ticker.C:
checkHealth()
}
}
}
context.WithTimeout¶
Cancels automatically after a duration. Most common in HTTP handlers and database calls.
func fetchUser(ctx context.Context, id int) (*User, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
row := db.QueryRowContext(ctx, "SELECT * FROM users WHERE id = $1", id)
var u User
if err := row.Scan(&u.ID, &u.Name, &u.Email); err != nil {
return nil, fmt.Errorf("scanning user %d: %w", id, err)
}
return &u, nil
}
context.WithDeadline¶
Like WithTimeout but takes an absolute time instead of a duration.
deadline := time.Now().Add(30 * time.Second)
ctx, cancel := context.WithDeadline(ctx, deadline)
defer cancel()
WithTimeout(ctx, 3*time.Second) is shorthand for WithDeadline(ctx, time.Now().Add(3*time.Second)).
context.WithValue¶
Attaches a key-value pair to the context. Used for request-scoped data like request IDs, authenticated users, and trace spans.
type contextKey string
const requestIDKey contextKey = "requestID"
func middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := uuid.New().String()
ctx := context.WithValue(r.Context(), requestIDKey, id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func handler(w http.ResponseWriter, r *http.Request) {
reqID, ok := r.Context().Value(requestIDKey).(string)
if !ok {
reqID = "unknown"
}
log.Printf("[%s] handling request", reqID)
}
Always use an unexported custom type for context keys
Using strings or other common types as keys risks collisions between packages. Define a private type: type contextKey string.
Context in HTTP Handlers¶
Every *http.Request carries a context. The server cancels it when the client disconnects.
func slowHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
result := make(chan string, 1)
go func() {
result <- expensiveComputation()
}()
select {
case <-ctx.Done():
http.Error(w, "request canceled", http.StatusRequestTimeout)
return
case res := <-result:
fmt.Fprint(w, res)
}
}
Context in Database Operations¶
The database/sql package supports context throughout:
func transferFunds(ctx context.Context, db *sql.DB, from, to int, amount float64) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("beginning transaction: %w", err)
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance - $1 WHERE id = $2", amount, from)
if err != nil {
return fmt.Errorf("debiting account %d: %w", from, err)
}
_, err = tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance + $1 WHERE id = $2", amount, to)
if err != nil {
return fmt.Errorf("crediting account %d: %w", to, err)
}
return tx.Commit()
}
Cancellation Propagation¶
Contexts form a tree. Canceling a parent cancels all children.
func main() {
ctx, cancel := context.WithCancel(context.Background())
go serviceA(ctx) // derives its own children
go serviceB(ctx) // derives its own children
time.Sleep(10 * time.Second)
cancel() // cancels serviceA, serviceB, and ALL their descendants
}
Checking Context State¶
select {
case <-ctx.Done():
fmt.Println(ctx.Err()) // context.Canceled or context.DeadlineExceeded
default:
// context still active
}
// Or check deadline directly
if deadline, ok := ctx.Deadline(); ok {
remaining := time.Until(deadline)
fmt.Printf("time remaining: %v\n", remaining)
}
Quick Reference¶
| Function | Creates | Auto-cancels? | Use Case |
|---|---|---|---|
context.Background() |
Root context | Never | main(), init, tests |
context.TODO() |
Root context | Never | Placeholder during refactoring |
context.WithCancel(parent) |
Cancelable child | On cancel() call |
Manual cancellation |
context.WithTimeout(parent, d) |
Deadline child | After duration d |
HTTP calls, DB queries |
context.WithDeadline(parent, t) |
Deadline child | At time t |
Absolute deadline |
context.WithValue(parent, k, v) |
Value-carrying child | Inherits parent | Request ID, auth user, trace |
Method on context.Context |
Returns |
|---|---|
Done() |
<-chan struct{} — closed when context is canceled |
Err() |
nil, context.Canceled, or context.DeadlineExceeded |
Deadline() |
Deadline time and whether one is set |
Value(key) |
Value for key, or nil |
Best Practices¶
- First parameter, named
ctx—func DoWork(ctx context.Context, ...)is the universal convention. - Always call
cancel()— usedefer cancel()immediately after creation to prevent goroutine/resource leaks. - Don't store context in structs — pass it explicitly through function parameters. Storing it breaks the request lifecycle model.
- Use
WithTimeoutfor external calls — never let an HTTP request, database query, or RPC call run unbounded. - Keep
WithValueminimal — only for request-scoped data that crosses API boundaries (request ID, auth token, trace span). Never use it to pass function parameters. - Pass the most specific context — if you have a timeout context derived from a request context, pass the timeout context to downstream calls.
Common Pitfalls¶
Forgetting to call cancel()
func handle(ctx context.Context) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
// BUG: cancel never called — goroutine and timer leak until parent cancels
// FIX: always defer cancel
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
}
cancel() releases the timer resources immediately.
Using context.WithValue as a function parameter bag
Context values are untyped, invisible to function signatures, and impossible to discover without reading source code. Reserve them for cross-cutting concerns like tracing and auth.Storing context in a struct field
// WRONG — context is tied to a request lifecycle, not an object lifecycle
type Server struct {
ctx context.Context
}
// RIGHT — pass context per-call
func (s *Server) HandleRequest(ctx context.Context, req *Request) error { ... }
context.Context docs explicitly state: "Do not store Contexts inside a struct type."
Ignoring ctx.Done() in long-running loops
// BUG: if context is canceled, this loop runs forever
func process(ctx context.Context, items []Item) {
for _, item := range items {
handle(item) // never checks ctx
}
}
// FIX: check context in the loop
func process(ctx context.Context, items []Item) error {
for _, item := range items {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
handle(item)
}
return nil
}
Performance Considerations¶
- Context creation is cheap —
WithCancel,WithTimeout, andWithValueallocate a small struct. Don't hesitate to create derived contexts. Value()lookup is O(n) in the depth of the context chain. Keep the chain shallow (typically 3–5 levels). Don't use it as a key-value store with dozens of entries.- Timer resources:
WithTimeoutandWithDeadlinecreate an internal timer. Callingcancel()stops the timer and frees memory — this is whydefer cancel()matters even on the happy path. - Channel close is cheap:
Done()returns a channel that's closed on cancellation. Theselecton a closed channel returns immediately — no performance concern for checking frequently.
Interview Tips¶
Interview Tip
"What is context used for in Go?" Context carries cancellation signals, deadlines, and request-scoped values across API boundaries. It's how you implement timeouts on HTTP calls, cancel database queries when a user disconnects, and propagate request IDs for tracing. It's the first parameter of any function that does I/O or long-running work.
Interview Tip
"What's the difference between WithTimeout and WithDeadline?" WithTimeout takes a duration (relative), WithDeadline takes an absolute time. WithTimeout(ctx, 5*time.Second) is literally shorthand for WithDeadline(ctx, time.Now().Add(5*time.Second)). Use WithTimeout in most cases; use WithDeadline when you have a fixed wall-clock deadline.
Interview Tip
"Why should you always defer cancel()?" Two reasons: (1) it releases internal timer resources immediately instead of waiting for the timeout to expire, and (2) it prevents goroutine leaks — any goroutine waiting on ctx.Done() will be unblocked. Even if the operation finishes before the deadline, calling cancel() is necessary for cleanup.
Interview Tip
"When should you use context.TODO() vs context.Background()?" Both return identical empty contexts. Background() is for top-level calls — main(), init(), test setup. TODO() signals intent: "I know I need a real context here, but I haven't plumbed it through yet." It's a refactoring breadcrumb.
Key Takeaways¶
- Context carries cancellation, deadlines, and request-scoped values through the call stack.
- Always pass
context.Contextas the first parameter, namedctx. - Always
defer cancel()immediately after creating a derived context. - Use
WithTimeoutfor external calls (HTTP, DB, RPC) — never let them run unbounded. - Use
WithValuesparingly — only for cross-cutting concerns, never for function parameters. - Don't store
context.Contextin struct fields — pass it per-call. - Canceling a parent context cancels all its children recursively.