Go’s context package is fundamental to writing effective concurrent applications. I’ll explore essential patterns that make contexts powerful for managing cancellations, timeouts, and request-scoped values.
Context Fundamentals
The context package in Go provides a standardized way to carry deadlines, cancellation signals, and request-scoped values across API boundaries and between processes. It forms the backbone of many Go applications, especially those dealing with network operations.
// Creating a basic context
ctx := context.Background() // Empty root context
ctx := context.TODO() // Placeholder when uncertain which context to use
These two functions create root contexts - Background() is used for the main function or initialization, while TODO() indicates a context should be used but it’s not yet clear which one.
Pattern 1: Proper Cancellation Propagation
Cancellation is the most common use of contexts. It allows signals to propagate through call chains, telling goroutines to stop work and release resources.
func processRequest(ctx context.Context) error {
// Start a database operation
resultCh := make(chan Result, 1)
errorCh := make(chan error, 1)
go func() {
result, err := db.Query(ctx, "SELECT * FROM users")
if err != nil {
errorCh <- err
return
}
resultCh <- result
}()
// Wait for result or cancellation
select {
case result := <-resultCh:
return processResult(result)
case err := <-errorCh:
return err
case <-ctx.Done():
return ctx.Err() // Returns context.Canceled or context.DeadlineExceeded
}
}
This pattern ensures resources aren’t wasted on work that’s no longer needed. I’ve used it countless times to prevent “runaway” goroutines when a user cancels a request or navigates away from a page.
Pattern 2: Request Timeouts
Timeouts protect your application from getting stuck waiting for operations that might never complete.
func handleRequest(w http.ResponseWriter, r *http.Request) {
// Create a timeout context - 5 seconds for the entire request
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel() // Always call cancel to release resources
result, err := performBusinessLogic(ctx)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "Request timed out", http.StatusGatewayTimeout)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(result)
}
I remember working on an API that would occasionally hang when a database connection failed. Adding timeout contexts immediately improved reliability by ensuring clients weren’t left waiting indefinitely.
Pattern 3: Value Propagation
Contexts can carry request-scoped values through your application. This is ideal for data like user IDs, authentication tokens, or correlation IDs that shouldn’t be passed as function parameters.
type userIDKey struct{}
// Middleware to add authenticated user ID to context
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID := authenticateUser(r)
if userID == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Add user ID to context
ctx := context.WithValue(r.Context(), userIDKey{}, userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Retrieve user ID from context
func getUserID(ctx context.Context) (string, bool) {
userID, ok := ctx.Value(userIDKey{}).(string)
return userID, ok
}
Use custom types as keys to prevent collisions and provide type safety. I’ve found this pattern invaluable for maintaining clean function signatures while ensuring request-specific data flows through the system.
Pattern 4: Context Hierarchies
Contexts form parent-child relationships where canceling a parent automatically cancels all children.
func processComplexRequest(ctx context.Context) error {
// Create a child context for the database operation
dbCtx, dbCancel := context.WithTimeout(ctx, 2*time.Second)
defer dbCancel()
// Start database operation
userCh := make(chan User, 1)
dbErrCh := make(chan error, 1)
go fetchUser(dbCtx, userCh, dbErrCh)
// Create a different child context for the API call
apiCtx, apiCancel := context.WithTimeout(ctx, 3*time.Second)
defer apiCancel()
// Start API operation
dataCh := make(chan APIData, 1)
apiErrCh := make(chan error, 1)
go callExternalAPI(apiCtx, dataCh, apiErrCh)
// Parallel operations with different timeouts but same parent context
var user User
var apiData APIData
// Complex select pattern to handle multiple operations
for remaining := 2; remaining > 0; {
select {
case user = <-userCh:
remaining--
case err := <-dbErrCh:
return fmt.Errorf("database error: %w", err)
case apiData = <-dataCh:
remaining--
case err := <-apiErrCh:
return fmt.Errorf("API error: %w", err)
case <-ctx.Done():
return ctx.Err()
}
}
return combineResults(user, apiData)
}
This pattern creates a tree of contexts, each with specific behaviors but all inheriting the cancellation of their parent. If the root request is canceled, all operations stop.
Pattern 5: Parent-Independent Timeouts
Sometimes operations need timeouts that function independently of parent cancellation.
func fetchDataWithRetry(parentCtx context.Context) ([]byte, error) {
var lastErr error
for attempts := 0; attempts < 3; attempts++ {
// Create a fresh timeout for each attempt
// This timeout is independent of previous attempts' timeouts
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
data, err := callService(ctx)
cancel() // Always cancel to avoid leaks
if err == nil {
return data, nil
}
// If parent context is canceled, stop retrying
if errors.Is(parentCtx.Err(), context.Canceled) {
return nil, parentCtx.Err()
}
// If it's a timeout, try again; otherwise return the error
if !errors.Is(err, context.DeadlineExceeded) {
return nil, err
}
lastErr = err
time.Sleep(time.Millisecond * 100 * time.Duration(attempts+1))
}
return nil, fmt.Errorf("maximum retry attempts reached: %w", lastErr)
}
I’ve used this pattern in systems that need to retry operations while still respecting an overall deadline. Creating a fresh context for each attempt allows precise control over how timeouts behave.
Pattern 6: Error Channel Communication
Context cancellation works well with Go’s channel-based concurrency patterns.
func processWithCancellation(ctx context.Context, input <-chan int) ([]int, error) {
results := make([]int, 0)
for {
select {
case val, ok := <-input:
if !ok {
// Channel closed, we're done
return results, nil
}
processed, err := processValue(ctx, val)
if err != nil {
return results, err
}
results = append(results, processed)
case <-ctx.Done():
return results, fmt.Errorf("processing canceled: %w", ctx.Err())
}
}
}
func processValue(ctx context.Context, val int) (int, error) {
// Simulate a processor that respects context cancellation
result := make(chan int, 1)
errCh := make(chan error, 1)
go func() {
// Simulate work
time.Sleep(100 * time.Millisecond)
select {
case <-ctx.Done():
errCh <- ctx.Err()
default:
result <- val * 2
}
}()
select {
case r := <-result:
return r, nil
case err := <-errCh:
return 0, err
case <-ctx.Done():
return 0, ctx.Err()
}
}
This pattern leverages select statements to handle both data flow and context cancellation elegantly. I find this approach particularly useful when processing streams of data that might need to be stopped at any point.
Pattern 7: Middleware Integration
Context is perfect for HTTP middleware to enrich requests with metadata.
// Trace middleware adds request tracing information
func TraceMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Generate trace ID or use one from header if present
traceID := r.Header.Get("X-Trace-ID")
if traceID == "" {
traceID = generateTraceID()
}
// Add tracing to context
ctx := context.WithValue(r.Context(), traceIDKey{}, traceID)
// Add trace ID to response headers
w.Header().Set("X-Trace-ID", traceID)
// Continue with enriched context
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Get trace ID anywhere in the request chain
func getTraceID(ctx context.Context) string {
if id, ok := ctx.Value(traceIDKey{}).(string); ok {
return id
}
return "unknown"
}
// Logger function that uses trace ID from context
func logWithContext(ctx context.Context, message string) {
traceID := getTraceID(ctx)
log.Printf("[%s] %s", traceID, message)
}
This pattern enables consistent tracing across an entire request lifecycle. In production systems, I’ve seen this approach simplify debugging tremendously by making it possible to correlate logs across multiple services.
Pattern 8: Context Benchmarking
It’s important to understand the performance implications of contexts in hot code paths.
func BenchmarkWithContext(b *testing.B) {
ctx := context.Background()
b.ResetTimer()
for i := 0; i < b.N; i++ {
processRequestWithContext(ctx, "test")
}
}
func BenchmarkWithoutContext(b *testing.B) {
for i := 0; i < b.N; i++ {
processRequestWithoutContext("test")
}
}
func processRequestWithContext(ctx context.Context, input string) string {
select {
case <-ctx.Done():
return ""
default:
return strings.ToUpper(input)
}
}
func processRequestWithoutContext(input string) string {
return strings.ToUpper(input)
}
While contexts are invaluable, they do add overhead. In performance-critical code, it’s worth measuring this impact. I’ve found that in most cases, the benefits of proper cancellation outweigh the small performance cost.
Pattern 9: Tracing and Observability
Contexts enable distributed tracing across service boundaries.
type SpanKey struct{}
func StartOperation(ctx context.Context, name string) (context.Context, func()) {
parentSpan, hasParent := ctx.Value(SpanKey{}).(*Span)
span := &Span{
Name: name,
StartTime: time.Now(),
ParentID: "",
}
if hasParent {
span.ParentID = parentSpan.ID
}
span.ID = generateSpanID()
// Store span in context
newCtx := context.WithValue(ctx, SpanKey{}, span)
return newCtx, func() {
span.EndTime = time.Now()
span.Duration = span.EndTime.Sub(span.StartTime)
sendSpanToCollector(span)
}
}
func CallDatabaseWithTracing(ctx context.Context, query string) ([]Row, error) {
ctx, endSpan := StartOperation(ctx, "database.query")
defer endSpan()
// Add the query to the span for debugging
if span, ok := ctx.Value(SpanKey{}).(*Span); ok {
span.Attributes["query"] = query
}
// Execute the database query
return db.Query(ctx, query)
}
This pattern enables detailed tracing of operations within and across services. I’ve implemented similar tracing in microservice architectures, making it possible to reconstruct the full path of a request through dozens of services.
Pattern 10: Graceful Shutdown with Context
Context can coordinate graceful application shutdown.
func main() {
// Create a root context that will be canceled on shutdown
ctx, cancel := context.WithCancel(context.Background())
// Set up signal handling
signalCh := make(chan os.Signal, 1)
signal.Notify(signalCh, syscall.SIGINT, syscall.SIGTERM)
// Start HTTP server
server := &http.Server{
Addr: ":8080",
Handler: setupHandlers(),
}
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server error: %v", err)
}
}()
// Start background worker
go runBackgroundWorker(ctx)
// Wait for termination signal
<-signalCh
log.Println("Shutdown signal received, initiating graceful shutdown...")
// Cancel context to notify all background workers
cancel()
// Create a timeout context for shutdown
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
// Gracefully shutdown the server
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("Server shutdown error: %v", err)
}
log.Println("Server gracefully stopped")
}
func runBackgroundWorker(ctx context.Context) {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
processBackgroundTask()
case <-ctx.Done():
log.Println("Background worker shutting down")
return
}
}
}
This pattern ensures all components of your application terminate gracefully when shutdown is initiated. I’ve implemented similar shutdown sequences for services that need to complete in-flight work before terminating.
Go’s context package is an elegant solution for many challenging problems in concurrent programming. When used properly, it simplifies cancellation propagation, timeout handling, and value passing without cluttering function signatures. The patterns I’ve shared come from real-world experience building production Go applications.
Remember that contexts should flow through your application like water – from high-level handlers down to the lowest-level functions that perform I/O or launch goroutines. By consistently applying these patterns, you’ll build more robust, maintainable, and reliable Go applications.