Go’s context package is one of the most frequently used and frequently misunderstood parts of the standard library. It appears in every HTTP handler, every database call, every RPC client — yet many developers treat it as a necessary ceremony rather than a powerful tool for controlling cancellation, timeouts, and request-scoped values. Let’s look at how to use it correctly, with patterns you can apply immediately.
The Core Rule: Context Flows Downward
A context travels from the entry point of your application (HTTP request, gRPC call, CLI command) downward through every function call. Each layer can derive a child context with additional behavior — a timeout, a cancellation channel, or a value — without affecting the parent.
func handleRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) {
// Derive a context that times out after 5 seconds
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel() // Always call cancel to release resources
user, err := fetchUser(ctx, userID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// This call inherits the 5-second deadline
orders, err := fetchOrders(ctx, user.ID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(orders)
}
The defer cancel() pattern is critical. If you create a context with a timeout or cancellation function, you must call cancel() to release the goroutine that the context package spawns internally. Forgetting this is a goroutine leak.
Pattern 1: Timeout Per Layer
Different operations have different acceptable latencies. A database query might need 2 seconds, a cache lookup 50 milliseconds, and an external API call 10 seconds. Apply timeouts at each boundary:
type Repository struct {
db *sql.DB
}
func (r *Repository) GetUser(ctx context.Context, id int64) (*User, error) {
// Database operations should be fast
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
var user User
err := r.db.QueryRowContext(ctx,
"SELECT id, name, email FROM users WHERE id = $1", id,
).Scan(&user.ID, &user.Name, &user.Email)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return nil, fmt.Errorf("database query timed out for user %d", id)
}
return nil, fmt.Errorf("querying user %d: %w", id, err)
}
return &user, nil
}
func (r *Repository) GetUserWithCache(ctx context.Context, id int64) (*User, error) {
// Cache lookup should be nearly instant
cacheCtx, cacheCancel := context.WithTimeout(ctx, 50*time.Millisecond)
defer cacheCancel()
if cached, ok := r.cache.Get(cacheCtx, fmt.Sprintf("user:%d", id)); ok {
var user User
if err := json.Unmarshal(cached, &user); err == nil {
return &user, nil
}
}
// Fall through to database (inherits parent context, not cache context)
return r.GetUser(ctx, id)
}
Notice how GetUserWithCache creates a tighter timeout for the cache lookup but passes the original ctx to the database call. The child cache context expires after 50ms, but the database call still has the remaining time from the parent context.
Pattern 2: Cancellation Propagation in Goroutines
When you fan out work across goroutines, context cancellation lets you short-circuit remaining work when one goroutine fails or the client disconnects:
func fetchFromSources(ctx context.Context, sources []string) ([]Result, error) {
results := make([]Result, len(sources))
errc := make(chan error, len(sources))
for i, source := range sources {
i, source := i, source // capture
go func() {
select {
case <-ctx.Done():
errc <- ctx.Err()
return
default:
}
result, err := fetchOne(ctx, source)
if err != nil {
errc <- err
return
}
results[i] = result
errc <- nil
}()
}
// Wait for all or first error
for range sources {
if err := <-errc; err != nil {
return nil, err // context cancel propagates to remaining goroutines
}
}
return results, nil
}
Pattern 3: Never Store Context in Structs
The single most common mistake is storing context as a struct field. Context is request-scoped — it should be passed as the first parameter of every function that needs it. A context stored in a struct can outlive the request it was created for, leading to subtle bugs where cancellations and timeouts fire at unexpected times.
// ❌ Wrong — context in struct
type Service struct {
ctx context.Context // this context will outlive any single request
db *sql.DB
}
func (s *Service) GetUser(id int64) (*User, error) {
return s.db.QueryRowContext(s.ctx, "SELECT ...", id) // whose context is this?
}
// ✅ Correct — context as first parameter
type Service struct {
db *sql.DB
}
func (s *Service) GetUser(ctx context.Context, id int64) (*User, error) {
return s.db.QueryRowContext(ctx, "SELECT ...", id)
}
Pattern 4: Checking Context in Loops
Long-running operations should check ctx.Err() periodically. This is especially important in batch processing and background workers:
func processBatch(ctx context.Context, items []Item) error {
for i, item := range items {
// Check before each iteration
if err := ctx.Err(); err != nil {
return fmt.Errorf("batch interrupted at item %d/%d: %w", i, len(items), err)
}
if err := process(ctx, item); err != nil {
return fmt.Errorf("processing item %d: %w", i, err)
}
// Report progress periodically
if i%100 == 0 {
log.Printf("processed %d/%d items", i, len(items))
}
}
return nil
}
Pattern 5: Request-Scoped Values, Used Sparingly
Context can carry request-scoped values like trace IDs, user IDs, or authentication tokens. This is useful for middleware, but overusing it turns your context into a hidden global variable. A good rule: if you'd be uncomfortable making something a global variable, don't put it in context either.
type contextKey string
const userIDKey contextKey = "userID"
// In middleware
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID := authenticate(r)
if userID == 0 {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), userIDKey, userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// In handler — extract with type assertion
func getUserID(ctx context.Context) (int64, bool) {
id, ok := ctx.Value(userIDKey).(int64)
return id, ok
}
Use a custom type (not string) for context keys to avoid collisions with other packages that might use the same key name.
The Propagation Test
A simple way to verify your context usage: draw the call graph from your HTTP handler down to the database driver. Every function in that chain should accept context.Context as its first parameter and pass it to the next call. If there's a gap — a function that ignores context — that's where cancellations silently stop propagating, and a client disconnect leaves the server grinding away on abandoned work.