Context Propagation Patterns in Large Go Codebases
"Always pass context as the first argument" is Go wisdom that gets repeated without explanation. But in large codebases, context propagation becomes surprisingly nuanced. When should you use context.Background()? What goes in context values? Why is my goroutine not cancelling?
Let's dig into the patterns that actually work.
The Basics (And Why They're Not Enough)
The standard advice:
func DoSomething(ctx context.Context, args Args) error {
// Use ctx for cancellation, deadlines, and request-scoped values
}
This works until you have:
- Background workers that shouldn't be cancelled by request context
- Graceful shutdown that needs its own cancellation
- Nested goroutines with complex lifecycles
- Values that should (or shouldn't) cross goroutine boundaries
Pattern 1: Request Context vs. Application Context
The most common mistake is using request context for everything:
// WRONG: Background task inherits request cancellation
func (h *Handler) ProcessOrder(w http.ResponseWriter, r *http.Request) {
order := parseOrder(r)
// This goroutine dies when the HTTP request completes!
go h.sendNotification(r.Context(), order)
w.WriteHeader(http.StatusAccepted)
}
The fix is understanding different context scopes:
func (h *Handler) ProcessOrder(w http.ResponseWriter, r *http.Request) {
order := parseOrder(r)
// Create a new context for background work
// Inherit values (trace ID, etc.) but not cancellation
bgCtx := detachContext(r.Context())
go h.sendNotification(bgCtx, order)
w.WriteHeader(http.StatusAccepted)
}
// detachContext creates a new context that inherits values but not cancellation
func detachContext(ctx context.Context) context.Context {
// Create new context with application-level cancellation
return contextWithValues(context.Background(), ctx)
}
func contextWithValues(dst, src context.Context) context.Context {
// Copy relevant values from src to dst
if traceID := src.Value(traceIDKey); traceID != nil {
dst = context.WithValue(dst, traceIDKey, traceID)
}
if requestID := src.Value(requestIDKey); requestID != nil {
dst = context.WithValue(dst, requestIDKey, requestID)
}
return dst
}
Pattern 2: Graceful Shutdown Context
Your application needs its own cancellation hierarchy:
type Application struct {
ctx context.Context
cancel context.CancelFunc
}
func NewApplication() *Application {
ctx, cancel := context.WithCancel(context.Background())
return &Application{ctx: ctx, cancel: cancel}
}
func (a *Application) Run() error {
// All long-running goroutines use application context
go a.backgroundWorker(a.ctx)
go a.metricsReporter(a.ctx)
// HTTP server gets a derived context
return a.httpServer.Run(a.ctx)
}
func (a *Application) Shutdown() {
// Cancelling app context stops everything
a.cancel()
}
// HTTP handlers create request contexts derived from app context
func (a *Application) handleRequest(w http.ResponseWriter, r *http.Request) {
// Request context inherits app cancellation
ctx := r.Context()
// If app is shutting down, this context is already cancelled
select {
case <-ctx.Done():
http.Error(w, "Server shutting down", http.StatusServiceUnavailable)
return
default:
}
// Normal processing...
}
Pattern 3: Context Values Done Right
Context values are often misused. Here's what belongs in context vs. function parameters:
// BAD: Business logic in context
func ProcessOrder(ctx context.Context) error {
order := ctx.Value("order").(Order) // Type assertion panic waiting to happen
user := ctx.Value("user").(User) // Hidden dependencies
// ...
}
// GOOD: Context for cross-cutting concerns only
func ProcessOrder(ctx context.Context, order Order, user User) error {
// Trace ID, request ID, auth token - these are cross-cutting
traceID := tracing.TraceIDFromContext(ctx)
log.Info("Processing order", "trace_id", traceID, "order_id", order.ID)
// ...
}
Type-Safe Context Keys
// Define unexported key types to prevent collisions
type contextKey int
const (
traceIDKey contextKey = iota
requestIDKey
userClaimsKey
)
// Provide typed accessors
func TraceIDFromContext(ctx context.Context) string {
if v := ctx.Value(traceIDKey); v != nil {
return v.(string)
}
return ""
}
func ContextWithTraceID(ctx context.Context, traceID string) context.Context {
return context.WithValue(ctx, traceIDKey, traceID)
}
Pattern 4: Timeout Hierarchies
Nested operations need coordinated timeouts:
func (s *Service) ProcessWithTimeout(ctx context.Context) error {
// Overall operation timeout
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
// Step 1: Database query (should be fast)
dbCtx, dbCancel := context.WithTimeout(ctx, 5*time.Second)
defer dbCancel()
data, err := s.db.Query(dbCtx, query)
if err != nil {
return fmt.Errorf("database query: %w", err)
}
// Step 2: External API (slower)
// Remaining time from parent context
apiCtx, apiCancel := context.WithTimeout(ctx, 20*time.Second)
defer apiCancel()
result, err := s.api.Call(apiCtx, data)
if err != nil {
return fmt.Errorf("api call: %w", err)
}
return s.save(ctx, result)
}
Key insight: Child timeouts should be shorter than parent. If parent has 30s remaining and child needs 25s, child might get cancelled by parent before its own timeout.
Pattern 5: Goroutine Lifecycle Management
The classic leaked goroutine:
// WRONG: Goroutine never terminates
func (w *Worker) Start() {
go func() {
for {
w.process()
time.Sleep(time.Second)
}
}()
}
// RIGHT: Context-controlled lifecycle
func (w *Worker) Start(ctx context.Context) {
go func() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log.Info("Worker shutting down")
return
case <-ticker.C:
w.process(ctx)
}
}
}()
}
Waiting for Goroutines
type WorkerPool struct {
wg sync.WaitGroup
}
func (p *WorkerPool) StartWorker(ctx context.Context, id int) {
p.wg.Add(1)
go func() {
defer p.wg.Done()
for {
select {
case <-ctx.Done():
return
default:
p.doWork(ctx)
}
}
}()
}
func (p *WorkerPool) Shutdown(ctx context.Context) error {
// Wait for all workers with timeout
done := make(chan struct{})
go func() {
p.wg.Wait()
close(done)
}()
select {
case <-done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
Pattern 6: Database Connection Context
Database operations have their own context considerations:
func (r *Repository) GetUser(ctx context.Context, id string) (*User, error) {
// Query respects context cancellation
row := r.db.QueryRowContext(ctx, "SELECT * FROM users WHERE id = $1", id)
var user User
if err := row.Scan(&user.ID, &user.Name); err != nil {
return nil, err
}
return &user, nil
}
// But transactions need careful handling
func (r *Repository) TransferFunds(ctx context.Context, from, to string, amount int) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
// Ensure cleanup even if context is cancelled
defer func() {
if err != nil {
tx.Rollback() // Rollback ignores context cancellation
}
}()
// Operations within transaction
if err = r.debit(ctx, tx, from, amount); err != nil {
return err
}
if err = r.credit(ctx, tx, to, amount); err != nil {
return err
}
return tx.Commit()
}
Common Mistakes
1. Using context.Background() Everywhere
// BAD: Ignores cancellation signals
func (s *Service) DoWork() {
s.db.Query(context.Background(), query) // Won't cancel on shutdown
}
// GOOD: Propagate context
func (s *Service) DoWork(ctx context.Context) {
s.db.Query(ctx, query)
}
2. Storing Context in Structs
// BAD: Context stored in struct
type Service struct {
ctx context.Context // Don't do this
}
// GOOD: Pass context to methods
type Service struct{}
func (s *Service) Process(ctx context.Context) error {
// Use ctx here
}
3. Not Checking ctx.Done()
// BAD: Long loop ignores cancellation
func processItems(ctx context.Context, items []Item) {
for _, item := range items {
process(item) // Context cancellation ignored
}
}
// GOOD: Check cancellation periodically
func processItems(ctx context.Context, items []Item) error {
for _, item := range items {
select {
case <-ctx.Done():
return ctx.Err()
default:
process(item)
}
}
return nil
}
Key Takeaways
-
Request context ≠ application context. Background tasks should not inherit request cancellation.
-
Context values are for cross-cutting concerns: trace IDs, request IDs, auth claims. Not business data.
-
Type-safe context keys prevent collisions and make dependencies explicit.
-
Child timeouts should be shorter than parent. Otherwise, parent might cancel before child's timeout.
-
Always check ctx.Done() in long-running loops.
-
Don't store context in structs. Pass it as the first parameter.
-
Use defer cancel() to prevent context leaks.
Context propagation seems simple until your codebase grows. Get these patterns right early, and debugging concurrent code becomes much easier.