Go Context Package: Cancellation and Deadlines

Go's `context` package solves a fundamental problem in concurrent programming: how do you tell a goroutine to stop what it's doing? When you spawn goroutines to handle HTTP requests, database...

Key Insights

  • Context enables cooperative cancellation across goroutines by providing a standardized way to signal when operations should stop, preventing resource leaks and zombie goroutines
  • Always pass context as the first parameter to functions and use WithTimeout or WithDeadline for operations with time constraints rather than implementing custom timeout logic
  • Never store context in structs—pass it explicitly through the call chain to maintain clear cancellation boundaries and avoid subtle bugs with long-lived objects

Understanding the Context Package

Go’s context package solves a fundamental problem in concurrent programming: how do you tell a goroutine to stop what it’s doing? When you spawn goroutines to handle HTTP requests, database queries, or external API calls, you need a mechanism to cancel them when the client disconnects, a timeout expires, or the operation is no longer needed.

The context package provides this mechanism through a simple interface that carries deadlines, cancellation signals, and request-scoped values across API boundaries. It’s not optional—it’s essential for writing production-grade Go services.

The Context Interface

The context.Context interface defines four methods:

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}
  • Deadline() returns when the context will be cancelled automatically
  • Done() returns a channel that closes when the context is cancelled
  • Err() explains why the context was cancelled
  • Value() retrieves request-scoped values (use sparingly)

Every context forms a tree structure. When you create a child context from a parent, cancelling the parent automatically cancels all children. This cascading cancellation is the key to proper resource cleanup.

Start with context.Background() for top-level contexts (like main() or test functions) and context.TODO() when you’re unsure which context to use but plan to add one later:

func main() {
    ctx := context.Background()
    processRequest(ctx)
}

func processRequest(ctx context.Context) {
    // Use ctx throughout the call chain
}

Manual Cancellation

Use context.WithCancel() when you need explicit control over when to stop operations. It returns a new context and a cancel function:

func handleRequest(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithCancel(r.Context())
    defer cancel() // Always call cancel to release resources

    results := make(chan string, 3)
    
    // Spawn multiple workers
    for i := 0; i < 3; i++ {
        go worker(ctx, i, results)
    }

    // Wait for first result or cancellation
    select {
    case result := <-results:
        w.Write([]byte(result))
        cancel() // Cancel remaining workers
    case <-ctx.Done():
        http.Error(w, "Request cancelled", http.StatusRequestTimeout)
    }
}

func worker(ctx context.Context, id int, results chan<- string) {
    // Simulate work with cancellation checks
    for i := 0; i < 10; i++ {
        select {
        case <-ctx.Done():
            // Clean up and exit
            return
        default:
            time.Sleep(100 * time.Millisecond)
        }
    }
    results <- fmt.Sprintf("Worker %d completed", id)
}

The pattern here is critical: each worker checks ctx.Done() periodically. When the context is cancelled, the channel closes, and workers exit gracefully. Always defer the cancel function to prevent context leaks.

Timeouts and Deadlines

Most operations should have time limits. context.WithTimeout() creates a context that cancels automatically after a duration:

func queryDatabase(ctx context.Context, query string) ([]Result, error) {
    // Set 5-second timeout for this operation
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()

    resultCh := make(chan []Result, 1)
    errCh := make(chan error, 1)

    go func() {
        // Simulate database query
        time.Sleep(3 * time.Second)
        resultCh <- []Result{{ID: 1, Name: "Example"}}
    }()

    select {
    case results := <-resultCh:
        return results, nil
    case err := <-errCh:
        return nil, err
    case <-ctx.Done():
        return nil, ctx.Err() // Returns context.DeadlineExceeded
    }
}

context.WithDeadline() works similarly but takes an absolute time:

func callExternalAPI(ctx context.Context) (*Response, error) {
    // Must complete by specific time
    deadline := time.Now().Add(10 * time.Second)
    ctx, cancel := context.WithDeadline(ctx, deadline)
    defer cancel()

    req, err := http.NewRequestWithContext(ctx, "GET", "https://api.example.com", nil)
    if err != nil {
        return nil, err
    }

    client := &http.Client{}
    resp, err := client.Do(req)
    if err != nil {
        // Check if timeout caused the error
        if ctx.Err() == context.DeadlineExceeded {
            return nil, fmt.Errorf("API call timed out: %w", err)
        }
        return nil, err
    }
    defer resp.Body.Close()

    // Process response...
    return &Response{}, nil
}

Use WithTimeout when you know how long an operation should take. Use WithDeadline when you have an absolute time constraint (like a request that must complete before a specific timestamp).

Real-World Context Propagation

Here’s a complete example showing context flowing through an HTTP handler to a service layer and database:

func main() {
    http.HandleFunc("/users", getUsersHandler)
    http.ListenAndServe(":8080", nil)
}

func getUsersHandler(w http.ResponseWriter, r *http.Request) {
    // HTTP server provides base context
    ctx := r.Context()
    
    // Add timeout for entire request
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()

    users, err := fetchUsers(ctx)
    if err != nil {
        if ctx.Err() == context.DeadlineExceeded {
            http.Error(w, "Request timeout", http.StatusGatewayTimeout)
            return
        }
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    json.NewEncoder(w).Encode(users)
}

func fetchUsers(ctx context.Context) ([]User, error) {
    // Service layer receives context
    db := getDBConnection()
    
    // Pass context to database layer
    rows, err := db.QueryContext(ctx, "SELECT id, name FROM users")
    if err != nil {
        return nil, fmt.Errorf("query failed: %w", err)
    }
    defer rows.Close()

    var users []User
    for rows.Next() {
        // Check cancellation during iteration
        if ctx.Err() != nil {
            return nil, ctx.Err()
        }
        
        var u User
        if err := rows.Scan(&u.ID, &u.Name); err != nil {
            return nil, err
        }
        users = append(users, u)
    }

    return users, nil
}

type User struct {
    ID   int
    Name string
}

Notice how context flows from the HTTP request through the handler to the service and database layers. Each layer respects the cancellation signal. When the client disconnects or the timeout expires, all layers stop working.

Best Practices and Common Mistakes

Do: Pass context as the first parameter

// Correct
func ProcessData(ctx context.Context, data []byte) error

// Wrong
func ProcessData(data []byte, ctx context.Context) error

Don’t: Store context in structs

// Wrong - contexts in structs cause confusion about lifetime
type Worker struct {
    ctx context.Context
}

// Correct - pass context to methods
type Worker struct {
    // other fields
}

func (w *Worker) Process(ctx context.Context) error {
    // use ctx here
}

Do: Check for cancellation in loops

// Correct
for _, item := range items {
    select {
    case <-ctx.Done():
        return ctx.Err()
    default:
    }
    process(item)
}

// Wrong - no cancellation check, loop runs to completion
for _, item := range items {
    process(item)
}

Do: Always call cancel functions

// Correct - prevents context leak
ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second)
defer cancel()

// Wrong - context resources not released until timeout
ctx, _ := context.WithTimeout(parentCtx, 5*time.Second)

Don’t: Pass nil context

// Wrong
doWork(nil)

// Correct
doWork(context.Background())

When to Use What

Use context.WithCancel() when you need manual control—like cancelling multiple operations when one completes first, or implementing graceful shutdown.

Use context.WithTimeout() for operations with known duration limits—API calls, database queries, or any operation that shouldn’t run indefinitely.

Use context.WithDeadline() when you have absolute time constraints—batch jobs that must complete by a specific time, or coordinating multiple operations that share a deadline.

The context package isn’t complicated, but it’s powerful. Master these patterns and your Go services will handle cancellation cleanly, prevent resource leaks, and respect timeouts consistently. Every function that does I/O or spawns goroutines should accept a context as its first parameter. No exceptions.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.