DEV Community

Shrijith Venkatramana
Shrijith Venkatramana

Posted on

2 1 1 1 1

Go Contexts: A Practical Guide to Managing Concurrency and Cancellation

Hi there! I'm Shrijith Venkatrama, founder of Hexmos. Right now, I’m building LiveAPI, a first of its kind tool for helping you automatically index API endpoints across all your repositories. LiveAPI helps you discover, understand and use APIs in large tech infrastructures with ease.

Go’s concurrency model is one of its superpowers, but it can get tricky when you need to coordinate, cancel, or timeout operations. That’s where contexts come in—a powerful tool in Go’s standard library to manage goroutines, deadlines, and cancellations. In this post, we’ll dive deep into Go’s context package, explore its mechanics, and walk through practical examples you can run yourself. Let’s get started.

What Are Contexts in Go?

The context package, introduced in Go 1.7, provides a way to carry cancellation signals, deadlines, and request-scoped values across goroutines. Think of it as a control mechanism for managing the lifecycle of concurrent operations. Contexts are especially useful in scenarios like HTTP servers, database queries, or any task that might need to be canceled or timed out.

Key points:

  • Contexts are defined in the context package.
  • They help manage goroutine lifecycles by signaling cancellation or timeouts.
  • They’re widely used in APIs, servers, and distributed systems.

Here’s a simple example to show a context in action:

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    // Create a context with a 2-second timeout
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel() // Always call cancel to release resources

    go doWork(ctx)

    // Wait for the context to timeout or be canceled
    <-ctx.Done()
    fmt.Println("Main: Context canceled, reason:", ctx.Err())
}

func doWork(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("doWork: Stopped because:", ctx.Err())
            return
        default:
            fmt.Println("doWork: Working...")
            time.Sleep(500 * time.Millisecond)
        }
    }
}

// Output (after ~2 seconds):
// doWork: Working...
// doWork: Working...
// doWork: Working...
// doWork: Working...
// doWork: Stopped because: context deadline exceeded
// Main: Context canceled, reason: context deadline exceeded
Enter fullscreen mode Exit fullscreen mode

This code creates a context with a timeout, runs a goroutine, and stops it when the timeout expires. The select statement checks for cancellation, and ctx.Err() tells us why the context ended.

Official Go context package documentation

Why Contexts Matter for Concurrency

Concurrency in Go is built around goroutines and channels, but without proper coordination, you can end up with leaked goroutines or unresponsive systems. Contexts solve this by providing a standardized way to signal when a task should stop.

Key points:

  • Contexts prevent goroutines from running indefinitely.
  • They’re essential for handling external events like HTTP request cancellations.
  • They make your code more robust and resource-efficient.

Imagine a web server handling a request that queries a database. If the client disconnects, you want to stop the query to save resources. Contexts make this possible.

Problem Without Contexts Solution With Contexts
Goroutines keep running after a request is canceled Context signals cancellation to stop goroutines
No way to enforce timeouts Context enforces deadlines or timeouts
Resource leaks from abandoned tasks Context ensures cleanup with cancel()

Creating and Using Contexts

The context package provides several ways to create contexts. The most common starting point is context.Background(), which creates an empty, non-cancelable context. From there, you can derive new contexts with specific behaviors.

Key points:

  • Use context.Background() as the root context for top-level operations.
  • Derive contexts with context.WithCancel, context.WithTimeout, or context.WithDeadline.
  • Always call the cancel function to release resources.

Here’s an example showing different context types:

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    // 1. Context with cancellation
    ctx, cancel := context.WithCancel(context.Background())
    go workWithCancel(ctx)
    time.Sleep(1 * time.Second)
    cancel() // Manually cancel the context
    time.Sleep(100 * time.Millisecond) // Wait for goroutine to exit

    // 2. Context with timeout
    ctx, cancel = context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()
    go workWithTimeout(ctx)
    <-ctx.Done()
    fmt.Println("Main: Timeout context done, reason:", ctx.Err())

    // 3. Context with deadline
    deadline := time.Now().Add(1 * time.Second)
    ctx, cancel = context.WithDeadline(context.Background(), deadline)
    defer cancel()
    go workWithDeadline(ctx)
    <-ctx.Done()
    fmt.Println("Main: Deadline context done, reason:", ctx.Err())
}

func workWithCancel(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("workWithCancel: Stopped because:", ctx.Err())
            return
        default:
            fmt.Println("workWithCancel: Working...")
            time.Sleep(200 * time.Millisecond)
        }
    }
}

func workWithTimeout(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("workWithTimeout: Stopped because:", ctx.Err())
            return
        default:
            fmt.Println("workWithTimeout: Working...")
            time.Sleep(200 * time.Millisecond)
        }
    }
}

func workWithDeadline(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("workWithDeadline: Stopped because:", ctx.Err())
            return
        default:
            fmt.Println("workWithDeadline: Working...")
            time.Sleep(200 * time.Millisecond)
        }
    }
}

// Output:
// workWithCancel: Working...
// workWithCancel: Working...
// workWithCancel: Working...
// workWithCancel: Working...
// workWithCancel: Stopped because: context canceled
// workWithTimeout: Working...
// workWithTimeout: Working...
// workWithTimeout: Working...
// workWithTimeout: Working...
// workWithTimeout: Stopped because: context deadline exceeded
// Main: Timeout context done, reason: context deadline exceeded
// workWithDeadline: Working...
// workWithDeadline: Working...
// workWithDeadline: Stopped because: context deadline exceeded
// Main: Deadline context done, reason: context deadline exceeded
Enter fullscreen mode Exit fullscreen mode

This example shows three context types: manual cancellation, timeout, and deadline. Each goroutine stops when its context signals completion.

Passing Contexts Through Functions

Contexts are designed to be passed through your program’s call stack. You should never store contexts in structs or global variables—instead, pass them explicitly as the first parameter to functions.

Key points:

  • Always pass contexts as the first argument, typically named ctx.
  • Avoid storing contexts; they’re meant to be short-lived.
  • Use contexts to propagate cancellation across layers.

Here’s an example of a function chain using contexts:

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
    defer cancel()

    // Pass context through functions
    result, err := processRequest(ctx, "user123")
    if err != nil {
        fmt.Println("Error:", err)
    } else {
        fmt.Println("Result:", result)
    }
}

func processRequest(ctx context.Context, userID string) (string, error) {
    // Simulate some work
    result, err := fetchUserData(ctx, userID)
    if err != nil {
        return "", err
    }
    return fmt.Sprintf("Processed data for %s: %s", userID, result), nil
}

func fetchUserData(ctx context.Context, userID string) (string, error) {
    // Simulate a database call
    select {
    case <-time.After(2 * time.Second):
        return fmt.Sprintf("Data for %s", userID), nil
    case <-ctx.Done():
        return "", ctx.Err()
    }
}

// Output:
// Error: context deadline exceeded
Enter fullscreen mode Exit fullscreen mode

Here, the context’s timeout stops fetchUserData before it completes, propagating the error up the call stack.

Go blog on context usage

Handling Context Values

Contexts can carry request-scoped values, like user IDs or request metadata. This is done using context.WithValue, but use it sparingly—values should be rare and truly request-scoped.

Key points:

  • Use context.WithValue to attach key-value pairs to a context.
  • Avoid overuse; contexts aren’t a general-purpose data store.
  • Keys should be unexported types to prevent collisions.

Here’s an example:

package main

import (
    "context"
    "fmt"
)

type contextKey string

const userIDKey contextKey = "userID"

func main() {
    ctx := context.WithValue(context.Background(), userIDKey, "user123")

    // Pass context to a function
    processWithUserID(ctx)
}

func processWithUserID(ctx context.Context) {
    userID, ok := ctx.Value(userIDKey).(string)
    if !ok {
        fmt.Println("No user ID found in context")
        return
    }
    fmt.Println("Processing for user:", userID)
}

// Output:
// Processing for user: user123
Enter fullscreen mode Exit fullscreen mode

This code attaches a user ID to the context and retrieves it in a function. The custom contextKey type prevents key collisions.

Common Context Pitfalls

Using contexts incorrectly can lead to subtle bugs. Here are some common mistakes and how to avoid them.

Key points:

  • Always call cancel() to prevent resource leaks.
  • Don’t ignore ctx.Err() after ctx.Done() is signaled.
  • Avoid passing nil contexts; use context.Background() instead.
Mistake Fix
Forgetting to call cancel() Use defer cancel() after creating a context
Passing nil context Always start with context.Background() or context.TODO()
Overusing context.WithValue Use function parameters for most data

Here’s an example of a common mistake and its fix:

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
    // Forgot defer cancel() -- resource leak!
    go badWorker(ctx)
    time.Sleep(2 * time.Second)
    fmt.Println("Main: Done")
}

func badWorker(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("badWorker: Stopped")
            return
        default:
            fmt.Println("badWorker: Working...")
            time.Sleep(200 * time.Millisecond)
        }
    }
}

// Output (leaks resources):
// badWorker: Working...
// badWorker: Working...
// badWorker: Stopped
// Main: Done

// Fixed version:
func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
    defer cancel() // Fixed: release resources
    go goodWorker(ctx)
    time.Sleep(2 * time.Second)
    fmt.Println("Main: Done")
}

func goodWorker(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("goodWorker: Stopped")
            return
        default:
            fmt.Println("goodWorker: Working...")
            time.Sleep(200 * time.Millisecond)
        }
    }
}

// Output (no leaks):
// goodWorker: Working...
// goodWorker: Working...
// goodWorker: Stopped
// Main: Done
Enter fullscreen mode Exit fullscreen mode

Contexts in Real-World Applications

Contexts shine in real-world scenarios like HTTP servers or database clients. For example, in an HTTP server, the http.Request object includes a context that’s canceled when the client disconnects.

Key points:

  • Use req.Context() in HTTP handlers to respect client cancellations.
  • Contexts integrate with libraries like database/sql or gRPC.
  • They ensure resources are freed when operations are no longer needed.

Here’s an HTTP server example:

package main

import (
    "context"
    "fmt"
    "net/http"
    "time"
)

func main() {
    http.HandleFunc("/work", workHandler)
    fmt.Println("Server starting on :8080...")
    http.ListenAndServe(":8080", nil)
}

func workHandler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    select {
    case <-time.After(2 * time.Second):
        fmt.Fprintln(w, "Work completed!")
    case <-ctx.Done():
        fmt.Println("workHandler: Client disconnected, reason:", ctx.Err())
    }
}

// Output (if client disconnects early):
// workHandler: Client disconnected, reason: context canceled
Enter fullscreen mode Exit fullscreen mode

Test this by curling http://localhost:8080/work and canceling with Ctrl+C. The server detects the cancellation via the context.

Go HTTP server documentation

Best Practices for Using Contexts

To wrap up, here are actionable tips for using contexts effectively:

  • Start with context.Background() for top-level contexts.
  • Always defer cancel() to avoid resource leaks.
  • Pass contexts explicitly as the first parameter to functions.
  • Use context.WithTimeout or context.WithDeadline for time-sensitive operations.
  • Reserve context.WithValue for request-scoped data like user IDs.
  • Check ctx.Err() after ctx.Done() to understand why a context ended.
  • Test your code with cancellations and timeouts to ensure robustness.

By following these practices, you’ll write cleaner, more reliable concurrent Go code. Contexts are a small but mighty tool—use them wisely, and they’ll save you from concurrency headaches. Now go build something awesome with Go!

Redis image

Short-term memory for faster
AI agents

AI agents struggle with latency and context switching. Redis fixes it with a fast, in-memory layer for short-term context—plus native support for vectors and semi-structured data to keep real-time workflows on track.

Start building

Top comments (2)

Collapse
 
nevodavid profile image
Nevo David

pretty cool seeing all the real code, tbh i always wonder if habits or just sticking with best practices matter more for long-term success in stuff like this

Collapse
 
shrsv profile image
Shrijith Venkatramana

In my experience under time and money constraints - habit trumps best practices. Deep and intense training is key

DevCycle image

Ship Faster, Stay Flexible.

DevCycle is the first feature flag platform with OpenFeature built-in to every open source SDK, designed to help developers ship faster while avoiding vendor-lock in.

Start shipping

👋 Kindness is contagious

Explore this compelling article, highly praised by the collaborative DEV Community. All developers, whether just starting out or already experienced, are invited to share insights and grow our collective expertise.

A quick “thank you” can lift someone’s spirits—drop your kudos in the comments!

On DEV, sharing experiences sparks innovation and strengthens our connections. If this post resonated with you, a brief note of appreciation goes a long way.

Get Started