How to Pass Context Through Your Application

Pass context by creating it at the entry point and threading it as the first argument to every function in your call chain.

The request is gone, but your code keeps running

You are building a service that fetches data from three different databases and merges the results. The client sends a request, and your server starts the work. Halfway through, the client loses patience and closes the connection. Your server keeps running. It finishes the database queries, allocates memory for the result, and tries to send the response to a client that is already gone. You have wasted CPU cycles, held database connections open, and leaked memory. This happens in almost every Go program that does I/O unless you thread a cancellation signal through your entire call chain.

Context solves this. It carries a cancellation signal, a deadline, and request-scoped metadata from the entry point of your program all the way down to the leaf functions. When the signal fires, every function that respects the context stops what it is doing and returns.

Context is a tree, not a bag

Think of context like a walkie-talkie channel shared by a team. The team lead holds the master channel. When the lead says "abort," everyone on that channel hears it immediately and stops what they are doing. In Go, the context is that channel. It flows down from the top of your call stack. When a parent context cancels, all derived child contexts cancel automatically.

The context.Context type is an interface. It is immutable. You never modify a context after creating it. Instead, you create a new derived context that wraps the old one. This creates a tree structure. The root is usually context.Background(). You wrap it with timeouts, cancellation functions, or values. Each wrap adds a node to the tree. When a node cancels, it closes a channel that all children are listening to.

Go has a strict convention for context. The context always goes as the first parameter to any function that needs it. The parameter is almost always named ctx. This makes it easy to spot in long signatures and keeps the codebase consistent. Functions that take a context should respect cancellation and deadlines. If a function accepts a context, it must check for cancellation before starting expensive work and periodically during long operations.

Minimal example: timeout and cleanup

Here is the simplest pattern: create a context with a deadline, defer the cancel function to clean up, and pass it as the first argument to your work function.

package main

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

// doWork simulates a task that respects cancellation.
func doWork(ctx context.Context) error {
	// Check if the context is already done before starting heavy work.
	select {
	case <-ctx.Done():
		return ctx.Err()
	default:
	}

	// Simulate work that might take a while.
	// In real code, this would be a database call or network request.
	time.Sleep(2 * time.Second)
	return nil
}

func main() {
	// Create a context that cancels automatically after 1 second.
	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
	// Always call cancel to release resources held by the context.
	defer cancel()

	// Pass ctx as the first argument to the function.
	err := doWork(ctx)
	if err != nil {
		fmt.Println("Work stopped:", err)
	} else {
		fmt.Println("Work completed")
	}
}

Defer cancel. Always.

Walkthrough: what happens at runtime

The program starts by calling context.Background(). This returns a root context that never cancels and has no deadline. It is the zero-value context. You wrap it with context.WithTimeout. This creates a new context that will cancel itself after one second. It also returns a cancel function. Calling cancel stops the timer and closes the internal Done channel.

The defer cancel() ensures the cancel function runs when main returns. This releases the timer and any resources the context holds. If you forget to call cancel, the context and its parent stay alive until the garbage collector runs, which can cause memory leaks in long-running servers.

The call to doWork passes the context. Inside doWork, the first thing the code does is check ctx.Done(). This returns a channel that closes when the context is cancelled. The select statement waits on that channel. If the channel is closed, the case fires and the function returns ctx.Err(). If the channel is not closed, the default case runs immediately and the function proceeds.

In this example, doWork sleeps for two seconds. The context times out after one second. The timeout closes ctx.Done(). If doWork were checking the context during the sleep, it would wake up and return. Because time.Sleep does not accept a context, the sleep finishes, and the function returns success. In real code, you would use operations that accept context, like http.NewRequestWithContext or database queries, which check ctx.Done() internally.

Context is plumbing. Run it through every long-lived call site.

Realistic example: goroutines and HTTP handlers

The most common place you need context is when spawning a goroutine. A goroutine runs independently of the function that created it. If the function returns, the goroutine keeps running. If you are handling an HTTP request, the client might disconnect while the goroutine is still working. You need to pass the request context to the goroutine so it can stop when the request ends.

Here is how an HTTP handler spawns a goroutine that respects the request lifecycle.

package main

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

// handler ties a goroutine to the request context.
func handler(w http.ResponseWriter, r *http.Request) {
	// r.Context() cancels when the client closes the connection.
	ctx := r.Context()

	go func() {
		// Pass ctx so the goroutine stops when the request ends.
		if err := doWork(ctx); err != nil {
			fmt.Println("Stopped:", err)
		}
	}()

	w.WriteHeader(http.StatusAccepted)
}

// doWork checks ctx.Done() to respect cancellation.
func doWork(ctx context.Context) error {
	for i := 0; i < 5; i++ {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-time.After(100 * time.Millisecond):
		}
	}
	return nil
}

The handler extracts the context from the request using r.Context(). This context is tied to the incoming HTTP request. If the client disconnects, the context cancels. The handler spawns a goroutine and passes the context to doWork. The goroutine runs in the background. The handler returns immediately with a 202 Accepted status.

The worker function uses a select loop to check for cancellation. Each iteration simulates a step of work. The select waits on two channels. If ctx.Done() closes, the function returns immediately. If the timer fires, the loop continues. This pattern ensures the goroutine stops as soon as the context cancels, rather than waiting for the current step to finish.

The worst goroutine bug is the one that never logs.

Pitfalls and compiler errors

Context misuse rarely causes compiler errors. The compiler does not check if you pass a context to a goroutine. It does not check if you check ctx.Done() inside a loop. These mistakes show up at runtime as goroutine leaks or wasted resources.

If you forget to pass context to a goroutine, the goroutine keeps running after the request ends. This is a goroutine leak. The program does not crash, but resources accumulate. Database connections stay open, memory grows, and the server eventually slows down. Always pass context to every goroutine that does work on behalf of a request.

Context is immutable. You cannot change a context after creating it. If you try to assign a value directly, the compiler rejects it with ctx.Value undefined (type context.Context has no field or method Value). You must use context.WithValue to create a new derived context. The new context wraps the old one and adds the value. The old context remains unchanged.

Using context.WithValue is dangerous if overused. Values should only carry request-scoped metadata, like a trace ID or user ID. Do not use context to pass optional parameters or configuration. That makes functions harder to test and obscures their dependencies. If you find yourself passing many values through context, you are probably using it wrong.

When a context cancels, ctx.Err() returns an error that explains why. If a timeout triggers, the error is context deadline exceeded. If you call cancel() manually, the error is context canceled. You can check these errors to distinguish between a timeout and a manual cancellation.

Context is immutable. Derive, don't mutate.

Decision: when to use context variants

Go provides several functions to create derived contexts. Each one serves a specific purpose. Pick the right tool based on your requirements.

Use context.Background() when you are at the root of your call tree and have no existing context. This is the entry point for your application, such as the main function or the top of an HTTP handler.

Use context.WithCancel when you need to signal cancellation manually from a specific point in your code. This is useful when you want to stop a goroutine based on a condition that is not a timeout or deadline.

Use context.WithTimeout when an operation must finish within a fixed duration, like a database query that should not hang forever. The context cancels automatically after the duration elapses.

Use context.WithDeadline when you have an absolute time limit that spans multiple operations, ensuring all children respect the same cutoff. This is useful when you need to coordinate several tasks that must complete before a specific moment.

Use context.WithValue sparingly to pass request-scoped metadata, like a trace ID or user ID, down the call chain. Only use this for data that is needed by many functions in the call stack. Do not use it for optional parameters.

Use plain sequential code without context when the function is short, synchronous, and has no external dependencies that need cancellation. Adding context adds boilerplate. If the function returns immediately and does not block, context is unnecessary.

Trust the convention. First arg, named ctx.

Where to go next