The context package provides a standard way to handle cancellation, timeouts, and request-scoped values. While we covered the basics in section 6.5 (Patterns and Best Practices) of Chapter 6 (Concurrency), this section explores advanced patterns and best practices.
What is context?
context.Context is an interface that carries:
Context is important because it provides a standard interface that all Go libraries use for cancellation, enables cancellation to propagate through call chains, and ensures resources are released when cancelled.
Core interface
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
1. Context with timeout and cancellation
You can combine timeout and manual cancellation:
package main
import (
"context"
"fmt"
"time"
)
func operation(ctx context.Context) error {
// Check if already cancelled
if ctx.Err() != nil {
return ctx.Err()
}
// Do work with periodic cancellation checks
for i := 0; i < 10; i++ {
select {
case <-ctx.Done():
return ctx.Err()
default:
fmt.Printf("Working... %d\n", i)
time.Sleep(100 * time.Millisecond)
}
}
return nil
}
func main() {
// Context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
// Can also cancel manually
go func() {
time.Sleep(200 * time.Millisecond)
cancel() // Cancel before timeout
}()
err := operation(ctx)
if err != nil {
fmt.Printf("Error: %v\n", err) // context canceled
}
}
2. Context hierarchy
Contexts form a tree. Cancelling a parent cancels all children:
package main
import (
"context"
"fmt"
"time"
)
func worker(ctx context.Context, id int) {
for {
select {
case <-ctx.Done():
fmt.Printf("Worker %d: Cancelled\n", id)
return
default:
fmt.Printf("Worker %d: Working...\n", id)
time.Sleep(200 * time.Millisecond)
}
}
}
func main() {
// Parent context
parentCtx, parentCancel := context.WithCancel(context.Background())
defer parentCancel()
// Child contexts
child1Ctx, _ := context.WithTimeout(parentCtx, 1*time.Second)
child2Ctx, _ := context.WithTimeout(parentCtx, 2*time.Second)
// Start workers
go worker(child1Ctx, 1)
go worker(child2Ctx, 2)
// Cancel parent after 500ms (cancels all children)
time.Sleep(500 * time.Millisecond)
parentCancel()
time.Sleep(500 * time.Millisecond)
}
Context hierarchy matters because it enables cascading cancellation where one cancellation signal stops the entire operation tree, ensures resource management where all related operations stop together, and allows all goroutines to detect cancellation and clean up.
3. Context with values (advanced)
Use context values sparingly and with typed keys:
package main
import (
"context"
"fmt"
)
// Typed key to avoid collisions
type contextKey string
const (
userIDKey contextKey = "userID"
requestIDKey contextKey = "requestID"
)
// Helper functions for type-safe access
func WithUserID(ctx context.Context, userID int) context.Context {
return context.WithValue(ctx, userIDKey, userID)
}
func UserIDFromContext(ctx context.Context) (int, bool) {
userID, ok := ctx.Value(userIDKey).(int)
return userID, ok
}
func WithRequestID(ctx context.Context, requestID string) context.Context {
return context.WithValue(ctx, requestIDKey, requestID)
}
func RequestIDFromContext(ctx context.Context) (string, bool) {
requestID, ok := ctx.Value(requestIDKey).(string)
return requestID, ok
}
func processRequest(ctx context.Context) {
if userID, ok := UserIDFromContext(ctx); ok {
fmt.Printf("Processing request for user: %d\n", userID)
}
if requestID, ok := RequestIDFromContext(ctx); ok {
fmt.Printf("Request ID: %s\n", requestID)
}
}
func main() {
ctx := context.Background()
ctx = WithUserID(ctx, 12345)
ctx = WithRequestID(ctx, "req-abc-123")
processRequest(ctx)
}
Typed keys are important because using string keys can cause collisions. Typed keys prevent accidental overwrites:
// ❌ BAD: String keys can collide
ctx := context.WithValue(ctx, "userID", 123)
ctx = context.WithValue(ctx, "userID", "admin") // Overwrites!
// ✅ GOOD: Typed keys prevent collisions
type userIDKey struct{}
ctx := context.WithValue(ctx, userIDKey{}, 123)
4. Context in HTTP handlers
Context is essential for HTTP request handling:
package main
import (
"context"
"fmt"
"net/http"
"time"
)
func handler(w http.ResponseWriter, r *http.Request) {
// Request context (automatically cancelled when client disconnects)
ctx := r.Context()
// Add timeout
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
// Add request ID
requestID := r.Header.Get("X-Request-ID")
if requestID != "" {
ctx = context.WithValue(ctx, "requestID", requestID)
}
// Use context in operations
result, err := doWork(ctx)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "Result: %v", result)
}
func doWork(ctx context.Context) (string, error) {
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(2 * time.Second):
return "work done", nil
}
}
5. Context in database operations
package main
import (
"context"
"database/sql"
"time"
)
func queryWithContext(ctx context.Context, db *sql.DB) error {
// Context automatically cancels query if parent is cancelled
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
rows, err := db.QueryContext(ctx, "SELECT * FROM users")
if err != nil {
return err
}
defer rows.Close()
// Process rows (respects context cancellation)
for rows.Next() {
select {
case <-ctx.Done():
return ctx.Err()
default:
// Process row
}
}
return rows.Err()
}
1. Pass context as first parameter
Standard Go convention:
// ✅ GOOD: Context first
func DoSomething(ctx context.Context, param string) error {
// ...
}
// ❌ BAD: Context not first
func DoSomething(param string, ctx context.Context) error {
// ...
}
2. Don't store context in structs
// ❌ BAD: Storing context
type Service struct {
ctx context.Context
}
// ✅ GOOD: Pass context explicitly
type Service struct {
// No context field
}
func (s *Service) DoWork(ctx context.Context) error {
// Use ctx parameter
}
Don't store context in structs. Contexts are request-scoped and should flow through call chains. Storing context can lead to using stale contexts and makes testing harder since you can't easily inject different contexts.
3. Use context for cancellation, not for passing optional parameters
// ❌ BAD: Using context for optional data
func Process(ctx context.Context) {
debug := ctx.Value("debug").(bool) // Wrong use
}
// ✅ GOOD: Use function parameters for optional data
func Process(ctx context.Context, debug bool) {
// Use ctx for cancellation only
}
4. Always check ctx.Done() in loops
// ✅ GOOD: Check cancellation in loops
func processItems(ctx context.Context, items []Item) error {
for _, item := range items {
select {
case <-ctx.Done():
return ctx.Err()
default:
if err := processItem(ctx, item); err != nil {
return err
}
}
}
return nil
}
5. Use defer cancel()
Always defer cancel to ensure cleanup:
// ✅ GOOD: Always defer cancel
func operation() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() // Ensures cleanup even if function returns early
return doWork(ctx)
}
6. Don't pass nil context
// ❌ BAD: Nil context
func DoWork(ctx context.Context) {
if ctx == nil {
ctx = context.Background()
}
// ...
}
// ✅ GOOD: Use context.Background() as default
func DoWork(ctx context.Context) {
if ctx == nil {
ctx = context.Background()
}
// Or better: require non-nil context
}
7. Create context at the right level
// ✅ GOOD: Create context at request boundary
func HTTPHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() // Use request context
// Add timeouts/deadlines as needed
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
processRequest(ctx)
}
// ❌ BAD: Creating context too early
var globalCtx = context.Background() // Wrong!
8. Use context.WithoutCancel() when needed (Go 1.21+)
Sometimes you need to continue work even after cancellation:
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Cancel after 1 second
go func() {
time.Sleep(1 * time.Second)
cancel()
}()
// This will be cancelled
select {
case <-ctx.Done():
fmt.Println("Context cancelled")
case <-time.After(2 * time.Second):
fmt.Println("Timeout")
}
// This continues even after cancellation
backgroundCtx := context.WithoutCancel(ctx)
select {
case <-backgroundCtx.Done():
fmt.Println("Background cancelled") // Won't happen
case <-time.After(2 * time.Second):
fmt.Println("Background completed") // This happens
}
}
Common patterns
1. Request timeout pattern
func handleRequest(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
// All operations use ctx and respect timeout
if err := validate(ctx); err != nil {
return err
}
if err := process(ctx); err != nil {
return err
}
return save(ctx)
}
2. Graceful shutdown pattern
func server(ctx context.Context) error {
srv := &http.Server{Addr: ":8080"}
// Shutdown on context cancellation
go func() {
<-ctx.Done()
srv.Shutdown(context.Background())
}()
return srv.ListenAndServe()
}
3. Operation with retry pattern
func operationWithRetry(ctx context.Context, maxRetries int) error {
for i := 0; i < maxRetries; i++ {
if err := doOperation(ctx); err != nil {
if ctx.Err() != nil {
return ctx.Err() // Don't retry if context cancelled
}
continue
}
return nil
}
return fmt.Errorf("max retries exceeded")
}
The context package is essential for writing robust, cancellable Go programs. Use it for all operations that should respect cancellation, timeouts, and deadlines. Following these best practices ensures your code integrates well with the broader Go ecosystem.