9.2 Context Package

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.

Context Basics (Review)

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{}
}

Advanced Context Patterns

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()
}

Best Practices

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.