Understanding Context in Go - The Right Way to Pass It Around

Posted on Mon 23 March 2026 by Sanyam Khurana in Programming

If you've been writing Go for any amount of time, you've seen ctx context.Context as the first parameter in almost every function. When I first started with Go, I treated it like a formality - just pass it along and don't think about it. That's fine until your HTTP handler hangs for 30 seconds because a downstream API is unresponsive, or your goroutines keep running long after the user has closed their browser tab. That's when context starts to matter.

Let's understand what context actually is, why Go chose this pattern, and how to use it properly.

What is Context?

At its core, context.Context is an interface that carries three things:

  1. Deadlines and timeouts - "this operation must complete within 5 seconds"
  2. Cancellation signals - "stop whatever you're doing, nobody needs the result anymore"
  3. Request-scoped values - "here's the user ID and trace ID for this request"

The interface itself is tiny:

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key any) any
}

That's it. Four methods. But this small interface is the backbone of how Go handles request lifecycle, cancellation, and timeout propagation across your entire application.

Why Not Just Use Global Variables or Pass Values Directly?

You might wonder why we need context at all. Can't we just pass a timeout value or a cancel channel?

You could, but context solves a deeper problem. In a real application, a single HTTP request might touch 10 different functions, make 3 database queries, call 2 external APIs, and spawn a couple of goroutines. All of these need to:

  • Stop immediately if the client disconnects
  • Respect the same overall timeout
  • Have access to the same request metadata (trace ID, auth info)

Passing individual cancel channels and timeout values to all these functions would be a nightmare. Context bundles all of this into a single, composable value that flows through your call chain.

Creating Contexts

You never create a context from scratch (except at the very top of a call chain). Instead, you derive new contexts from existing ones. This creates a tree structure where cancelling a parent automatically cancels all children.

The Root: context.Background() and context.TODO()

Every context tree starts with a root:

// Use this at the top level: main(), init(), tests
ctx := context.Background()

// Use this when you're not sure which context to use yet
// It's a signal to other developers: "this needs a proper context later"
ctx := context.TODO()

Both return an empty context with no deadline, no cancellation, and no values. The difference is purely semantic - TODO() is a placeholder that says "I haven't figured out the right context to use here yet."

Adding a Timeout: context.WithTimeout

This is probably the most common one. You want an operation to fail if it takes too long:

// This context will automatically cancel after 5 seconds
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() // Always defer cancel to release resources

result, err := callExternalAPI(ctx)
if err != nil {
    if ctx.Err() == context.DeadlineExceeded {
        log.Println("API call timed out")
    }
    return err
}

The cancel function is returned alongside the context, and you must always call it - even if the timeout hasn't expired yet. The defer cancel() pattern handles this. If you skip calling cancel, you'll leak resources.

Adding a Deadline: context.WithDeadline

Similar to timeout, but you specify an absolute time:

// Must complete by a specific time
deadline := time.Now().Add(30 * time.Second)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()

WithTimeout is actually just syntactic sugar over WithDeadline. Under the hood, context.WithTimeout(ctx, 5*time.Second) is the same as context.WithDeadline(ctx, time.Now().Add(5*time.Second)).

Manual Cancellation: context.WithCancel

When you want to cancel things explicitly rather than by timeout:

ctx, cancel := context.WithCancel(context.Background())

go func() {
    // Do some work...
    result := doExpensiveComputation(ctx)
    if result.IsGoodEnough() {
        cancel() // Signal all other goroutines to stop
    }
}()

This is useful when you have multiple goroutines doing the same work and you want to cancel the rest once one of them finishes.

Carrying Values: context.WithValue

Context can carry request-scoped data:

type contextKey string

const userIDKey contextKey = "userID"

// Setting a value
ctx := context.WithValue(parentCtx, userIDKey, "user-123")

// Getting a value
userID, ok := ctx.Value(userIDKey).(string)
if !ok {
    log.Println("no user ID in context")
}

A few important things about WithValue:

  • Always use a custom unexported type for keys (not a bare string). This prevents key collisions between packages.
  • Context values are immutable. WithValue returns a new context - it doesn't modify the parent.
  • Don't use context for passing optional parameters or things that should be function arguments. Context values are for request-scoped metadata like trace IDs, auth tokens, and request IDs. If a function needs a database connection, pass it as a parameter, not through context.

How Context Flows Through Your Application

Here's a realistic example of context flowing through an HTTP handler:

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

func getUser(w http.ResponseWriter, r *http.Request) {
    // r.Context() gives us a context that cancels when the
    // client disconnects. This is our starting point.
    ctx := r.Context()

    // Add a timeout for the entire handler
    ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
    defer cancel()

    // This context now carries:
    // 1. Client disconnect cancellation (from r.Context())
    // 2. 10 second timeout (from WithTimeout)
    // Both will propagate to every function we pass ctx to.

    user, err := fetchUserFromDB(ctx, "user-123")
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    profile, err := fetchProfileFromAPI(ctx, user.ProfileURL)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

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

func fetchUserFromDB(ctx context.Context, id string) (*User, error) {
    // The database query will be cancelled if the context is done
    return db.QueryRowContext(ctx, "SELECT * FROM users WHERE id = $1", id).Scan(...)
}

func fetchProfileFromAPI(ctx context.Context, url string) (*Profile, error) {
    req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
    // This HTTP call will be cancelled if the context is done
    resp, err := http.DefaultClient.Do(req)
    // ...
}

Notice how the 10-second timeout applies to the entire handler - both the database query and the API call combined. If the database takes 7 seconds, the API call only has 3 seconds left. This is exactly the behavior you want.

And if the user closes their browser tab? r.Context() is cancelled, which cascades down to cancel the database query and the API call. No wasted resources.

Listening for Cancellation in Your Own Code

When you write functions that do long-running work, you should check for context cancellation:

func processItems(ctx context.Context, items []Item) error {
    for _, item := range items {
        // Check if context is cancelled before processing each item
        select {
        case <-ctx.Done():
            return ctx.Err()
        default:
        }

        if err := process(item); err != nil {
            return err
        }
    }
    return nil
}

The select with ctx.Done() and a default case is the standard pattern. The default case makes it non-blocking - if the context isn't cancelled, we proceed immediately.

For goroutines, you'll often use ctx.Done() in a select alongside your work channel:

func worker(ctx context.Context, jobs <-chan Job) {
    for {
        select {
        case <-ctx.Done():
            log.Println("worker shutting down:", ctx.Err())
            return
        case job := <-jobs:
            process(job)
        }
    }
}

Context Rules to Live By

The Go team has documented these conventions, and they're worth following:

  1. Context is always the first parameter, named ctx. Not the last, not embedded in a struct.
// Good
func DoSomething(ctx context.Context, id string) error

// Bad
func DoSomething(id string, ctx context.Context) error
  1. Never store context in a struct. Pass it explicitly through function calls. Storing context in a struct usually means you're holding onto a context longer than its intended lifecycle.
// Bad
type Server struct {
    ctx context.Context
}

// Good - pass it per-request
func (s *Server) HandleRequest(ctx context.Context, req *Request) error
  1. Don't pass nil context. If you're unsure, use context.TODO(). It's better than nil, which will cause a panic.

  2. Always call cancel. When you get a cancel function from WithTimeout, WithDeadline, or WithCancel, always call it. defer cancel() right after creation.

  3. Context values are for request-scoped data only. Don't use them for dependency injection or passing function arguments.

Common Mistakes

Ignoring the Parent's Deadline

If the parent context has a 5-second timeout and you create a child with a 10-second timeout, the effective timeout is still 5 seconds. The child can only be more restrictive, never less:

parentCtx, _ := context.WithTimeout(ctx, 5*time.Second)

// This child has an effective timeout of 5 seconds, not 10
childCtx, _ := context.WithTimeout(parentCtx, 10*time.Second)

Forgetting to Propagate Context

Using context.Background() in the middle of a call chain breaks the cancellation chain:

func handler(ctx context.Context) {
    // Bad: this ignores the parent's timeout and cancellation
    result := doWork(context.Background())

    // Good: propagate the incoming context
    result := doWork(ctx)
}

Using Context for Dependency Injection

// Bad - don't do this
ctx = context.WithValue(ctx, "db", database)
ctx = context.WithValue(ctx, "logger", logger)

// Good - pass dependencies explicitly
func NewService(db *sql.DB, logger *log.Logger) *Service

Summary

Context in Go is simple by design but powerful in practice. The key ideas:

  • It propagates cancellation and timeouts through your call chain automatically
  • It forms a tree - cancelling a parent cancels all children
  • Always pass it as the first function parameter
  • Always defer cancel() when you create a derived context
  • Use WithValue sparingly - only for request-scoped metadata, not for function arguments

Once you internalize the pattern, you'll find it hard to imagine writing Go without it. It's one of those things that looks like ceremony at first but saves you from entire categories of bugs - hung requests, leaked goroutines, and zombie processes.

If you've any questions about context in Go, please let us know in the comments section below.