02 - Context Deep Dive
📋 Jump to TakeawaysIn real Go code, context.Context is everywhere. Every HTTP handler, every database call, every goroutine that might need to stop. It's how you tell concurrent work: "time's up" or "we don't need this anymore."
Why Context Exists
Before context, the standard pattern was a manual done channel:
done := make(chan struct{})
go func() {
for {
select {
case <-done:
fmt.Println("stopping")
return
case job := <-jobs:
process(job)
}
}
}()
// Later: signal the goroutine to stop
close(done)This works, but every function needs its own done channel passed around. No standard way to add timeouts. No way to propagate cancellation through a call chain. Every team invented their own version.
Context standardizes this into a single interface that the entire ecosystem understands.
ctx, cancel := context.WithCancel(context.Background())
go func() {
for {
select {
case <-ctx.Done(): // same as <-done, but standard
fmt.Println("stopping:", ctx.Err())
return
case job := <-jobs:
process(job)
}
}
}()
cancel() // same as close(done), but also supports timeouts, deadlines, and propagationctx.Done() is the done channel. cancel() is close(done). But context adds timeouts, deadlines, value propagation, and parent-child cancellation for free.
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}Four methods. That's it. Done() returns a channel that closes when the context is cancelled. Err() tells you why.
context.Background and context.TODO
Every context chain starts with a root.
ctx := context.Background() // production code — the root context
ctx := context.TODO() // placeholder when you're not sure which context to use yetBackground is the standard root. TODO is for when you know you need a context but haven't wired it up yet. Both are identical in behavior — the difference is intent.
context.WithCancel
Create a context you can cancel manually.
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // always defer cancel to avoid leaks
done := make(chan struct{})
go func() {
defer close(done)
select {
case <-ctx.Done():
fmt.Println("cancelled:", ctx.Err())
return
case <-time.After(5 * time.Second):
fmt.Println("finished work")
}
}()
time.Sleep(1 * time.Second) // simulate doing something before cancelling
cancel() // signal the goroutine to stop
<-done // wait for goroutine to finish
}Output: cancelled: context canceled
The goroutine checks ctx.Done() in a select. When cancel() is called, the channel closes and the goroutine exits cleanly. Notice we use a done channel instead of time.Sleep to wait for the goroutine — that's the proper way.
context.WithTimeout
Cancel automatically after a duration.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
select {
case <-time.After(5 * time.Second):
fmt.Println("work done")
case <-ctx.Done():
fmt.Println("timed out:", ctx.Err())
}Output: timed out: context deadline exceeded
WithTimeout is syntactic sugar for WithDeadline with a relative time.
context.WithDeadline
Same as WithTimeout but with an absolute time.
deadline := time.Now().Add(3 * time.Second)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()Use WithTimeout for "give this 5 seconds." Use WithDeadline for "this must finish by 2:30 PM."
Checking the Deadline
You can check if a context has a deadline and how much time is left.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if deadline, ok := ctx.Deadline(); ok {
fmt.Println("deadline:", deadline)
fmt.Println("time left:", time.Until(deadline))
}Useful when you need to decide whether to start expensive work or bail early.
context.WithValue
Attach request-scoped data to a context. Common for request IDs, auth tokens, tracing.
type contextKey string
const requestIDKey contextKey = "requestID"
ctx := context.WithValue(context.Background(), requestIDKey, "abc-123")
// Later, in a different function:
if id, ok := ctx.Value(requestIDKey).(string); ok {
fmt.Println("request:", id)
}Why a custom type for keys? If two packages both use a plain string "id" as a key, one overwrites the other — collision. A custom type like type contextKey string is unique per package. Even if two packages define the same underlying string, Go treats them as different keys because the types are different. Think of it as a namespace.
// Bad — any package using "id" collides
ctx = context.WithValue(ctx, "id", "user-123")
// Good — only this package can access this key
type contextKey string
const idKey contextKey = "id"
ctx = context.WithValue(ctx, idKey, "user-123")Other rules:
- Don't use context for passing function parameters — it's for request-scoped metadata
- Values are immutable —
WithValuecreates a new context, doesn't modify the parent
Context Propagation
Contexts form a tree. Cancelling a parent cancels all children.
parent, cancelParent := context.WithCancel(context.Background())
child1, cancelChild1 := context.WithTimeout(parent, 5*time.Second)
child2, cancelChild2 := context.WithCancel(parent)
defer cancelChild1()
defer cancelChild2()
cancelParent() // cancels parent, child1, AND child2This is why you pass context through your call chain. An HTTP handler creates a context, passes it to a service function, which passes it to a database call. If the client disconnects, the entire chain cancels.
Real-World Pattern: HTTP Handler
func handler(w http.ResponseWriter, r *http.Request) {
// r.Context() is cancelled if the client disconnects
ctx := r.Context()
result, err := fetchData(ctx)
if err != nil {
if ctx.Err() != nil {
return // client gone, don't bother responding
}
http.Error(w, "error", 500)
return
}
fmt.Fprint(w, result)
}
func fetchData(ctx context.Context) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.example.com/data", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
// process response...
return "data", nil
}The timeout is layered on top of the request context. If the client disconnects OR 3 seconds pass, the HTTP call is cancelled.
Real-World Pattern: Worker with Context
func worker(ctx context.Context, id int, jobs <-chan int, wg *sync.WaitGroup) {
defer wg.Done()
for {
select {
case <-ctx.Done():
fmt.Printf("worker %d stopping: %v\n", id, ctx.Err())
return
case job, ok := <-jobs:
if !ok {
return // channel closed
}
fmt.Printf("worker %d processing job %d\n", id, job)
time.Sleep(500 * time.Millisecond) // simulate processing time
}
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
jobs := make(chan int)
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go worker(ctx, i, jobs, &wg)
}
for j := 0; j < 10; j++ {
select {
case jobs <- j:
case <-ctx.Done():
fmt.Println("stopping job dispatch:", ctx.Err())
goto done
}
}
done:
close(jobs)
wg.Wait()
}Workers check ctx.Done() alongside their job channel. When the context expires, everyone stops.
Common Mistakes
Not calling cancel. Every WithCancel, WithTimeout, and WithDeadline returns a cancel function. Always call it, even if the context expires on its own. It releases resources.
// Wrong
ctx, _ := context.WithTimeout(context.Background(), 5*time.Second)
// Right
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()Passing context as a struct field. Context should be the first parameter of a function, not stored in a struct.
// Wrong
type Server struct {
ctx context.Context
}
// Right
func (s *Server) Handle(ctx context.Context) { ... }Using context.Value for everything. It's not a general-purpose key-value store. Use it for request-scoped metadata (request ID, auth info, tracing). Pass real dependencies as function parameters.
Key Takeaways
context.Background()is the root — start every context chain hereWithCancelfor manual cancellation,WithTimeoutfor duration-based,WithDeadlinefor absolute time- Always
defer cancel()— even if the context times out on its own - Cancelling a parent cancels all children — contexts form a tree
- Check
ctx.Done()in select statements to make goroutines cancellable - Pass context as the first function parameter, never store it in a struct
- Use
WithValuesparingly — request IDs and tracing, not function arguments