In my experience building production systems with Go, I’ve found that how you handle contexts often determines the difference between a resilient service and a fragile one. Contexts aren’t just another parameter to pass around—they’re the connective tissue that keeps your concurrent operations coordinated and responsive.
Let me walk you through some practical patterns that have served me well across numerous projects.
When I first started working with contexts, timeouts were the most immediately valuable aspect. That initial example demonstrates a common scenario we all face: operations that might hang indefinitely without proper boundaries. I’ve seen services grind to a halt because someone forgot to set timeouts on database queries. The pattern is simple but powerful: wrap your context with a deadline and let the context handle the expiration logic.
Here’s how I typically implement timeout patterns in real applications:
func fetchUserData(ctx context.Context, userID string) (*User, error) {
// Set a specific timeout for this database operation
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
var user User
err := db.QueryRowContext(ctx,
"SELECT id, name, email FROM users WHERE id = $1", userID).Scan(
&user.ID, &user.Name, &user.Email)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
metrics.TimeoutCounter.Inc()
return nil, fmt.Errorf("database timeout for user %s", userID)
}
return nil, fmt.Errorf("database error: %w", err)
}
return &user, nil
}
Cancellation propagation is where contexts truly shine. Early in my career, I built a service that would continue processing requests even after clients disconnected. The waste was enormous. Now, I make sure every goroutine checks context cancellation regularly.
Consider this pattern for worker pools:
func processJobs(ctx context.Context, jobs <-chan Job) {
for {
select {
case job, ok := <-jobs:
if !ok {
return // channel closed
}
if err := processJob(ctx, job); err != nil {
log.Printf("Job %d failed: %v", job.ID, err)
}
case <-ctx.Done():
log.Printf("Stopping worker due to: %v", ctx.Err())
return
}
}
}
The beauty of this approach is that when the main context gets cancelled—maybe because the service is shutting down or a client disconnected—all workers stop cleanly. No more orphaned goroutines consuming resources.
Request-scoped values require careful consideration. I’ve made the mistake of putting too much in context values, which made code hard to follow and test. Now I reserve context values for cross-cutting concerns that genuinely need to propagate through many layers.
Here’s a pattern I use for request tracing:
type traceIDKey struct{}
func WithTraceID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, traceIDKey{}, id)
}
func GetTraceID(ctx context.Context) string {
if id, ok := ctx.Value(traceIDKey{}).(string); ok {
return id
}
return "unknown"
}
// Usage in HTTP middleware
func tracingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
traceID := generateTraceID()
ctx := WithTraceID(r.Context(), traceID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
This approach ensures every log message and external call can be correlated back to the original request, without cluttering function signatures.
Context inheritance is something I see developers get wrong frequently. The rule is simple: always derive from existing contexts. I once debugged a service where someone created new background contexts deep in the call stack, completely breaking the cancellation chain.
Here’s the correct pattern:
func handleRequest(ctx context.Context, req *Request) (*Response, error) {
// Derive a new context with a timeout specific to this operation
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
// This context will inherit cancellation from the parent
user, err := authenticate(ctx, req.Token)
if err != nil {
return nil, fmt.Errorf("authentication failed: %w", err)
}
// And pass it down to the next layer
data, err := fetchUserData(ctx, user.ID)
if err != nil {
return nil, fmt.Errorf("data fetch failed: %w", err)
}
return buildResponse(data), nil
}
Error handling around contexts requires attention to detail. I always check ctx.Done() in loops and potentially blocking operations. The context.Err() method tells you why the context was cancelled, which is valuable for metrics and logging.
Here’s a pattern I use for retry logic with context awareness:
func retryOperation(ctx context.Context, op func() error, maxRetries int) error {
for i := 0; i < maxRetries; i++ {
err := op()
if err == nil {
return nil
}
select {
case <-time.After(time.Duration(i) * time.Second):
// Exponential backoff
case <-ctx.Done():
return fmt.Errorf("operation cancelled: %w", ctx.Err())
}
}
return fmt.Errorf("max retries exceeded")
}
Database and HTTP integrations should always accept context parameters. I’ve standardized on making context the first parameter in all functions that do I/O. This consistency makes code easier to read and maintain.
Here’s how I structure database access layers:
type Repository struct {
db *sql.DB
}
func (r *Repository) GetUserByEmail(ctx context.Context, email string) (*User, error) {
const query = `SELECT id, name, email FROM users WHERE email = $1`
var user User
err := r.db.QueryRowContext(ctx, query, email).Scan(
&user.ID, &user.Name, &user.Email)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("query error: %w", err)
}
return &user, nil
}
And for HTTP clients:
func callExternalAPI(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("HTTP request failed: %w", err)
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
Testing context behavior is crucial. I write tests that verify functions properly respect cancellation and timeouts. This catches bugs early and ensures your system behaves correctly under stress.
Here’s a comprehensive test pattern I use:
func TestService_Timeout(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
srv := NewService()
_, err := srv.SlowMethod(ctx)
if !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("Expected deadline exceeded, got %v", err)
}
}
func TestService_Cancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
srv := NewService()
_, err := srv.SlowMethod(ctx)
if !errors.Is(err, context.Canceled) {
t.Errorf("Expected context cancelled, got %v", err)
}
}
Default contexts should be used carefully. I reserve context.Background() for initialization code and tests. In application code, I always try to work with a context that’s part of a cancellation chain.
Here’s how I handle the top-level context in main:
func main() {
ctx, stop := signal.NotifyContext(context.Background(),
os.Interrupt, syscall.SIGTERM)
defer stop()
// Pass this context to all components
server := NewServer()
if err := server.Run(ctx); err != nil {
log.Fatalf("Server failed: %v", err)
}
}
And in HTTP handlers:
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Use this context for all operations within this request
result, err := h.processRequest(ctx, r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}
Context values should be used sparingly. I limit them to infrastructure concerns that need to cross many boundaries. For application data, I prefer explicit parameters.
Here’s a pattern I use for authentication:
type userKey struct{}
func WithUser(ctx context.Context, user *User) context.Context {
return context.WithValue(ctx, userKey{}, user)
}
func UserFromContext(ctx context.Context) (*User, bool) {
user, ok := ctx.Value(userKey{}).(*User)
return user, ok
}
// In authentication middleware
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, err := authenticateRequest(r)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
ctx := WithUser(r.Context(), user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Throughout my journey with Go contexts, I’ve learned that consistency is key. Establish clear patterns early and stick to them. Make context the first parameter in all relevant functions. Always check for cancellation in loops and blocking operations. Use context values judiciously for cross-cutting concerns.
These patterns have helped me build systems that handle cancellation gracefully, respect timeouts consistently, and maintain clear request boundaries. They’re not just theoretical concepts—they’re practical tools that make real systems more robust and maintainable.
The context package might seem simple at first glance, but mastering its patterns requires thoughtful application. Start with timeouts and cancellation propagation, then gradually incorporate request-scoped values where they make sense. Test your context handling thoroughly, and you’ll build systems that behave predictably under all conditions.