How to use context package

Use the context package to manage deadlines, cancellation, and request-scoped values across goroutines and API boundaries.

The ghost request problem

You are building a web service. A client sends a request to fetch a report. Your handler spawns a goroutine to query the database and another to aggregate results from a cache. The client loses patience, closes the browser tab, and the connection drops. Your server doesn't know the client is gone. The goroutines keep running. They hold database connections open. They consume CPU cycles. They write to memory that no one will ever read. The work continues for a ghost.

Context stops this. It carries a cancellation signal that propagates down the call tree. When the root context cancels, every derived context knows to stop. It also carries deadlines and request-scoped values. Context is the standard way to manage the lifecycle of a request in Go.

Context as a carrier

Think of context like a shared clipboard that travels with a request. Every function in the chain can check the clipboard to see if the job is still active, if time is running out, or if there are specific instructions for this run. When the clipboard gets marked "CANCELLED", every function holding a copy knows to drop what it's doing and return immediately.

The clipboard has three slots:

  • A cancellation signal that closes when the work should stop.
  • A deadline or timeout that triggers cancellation automatically.
  • A small map for request-scoped values like trace IDs or user IDs.

Context forms a tree. context.Background() is the root. It never cancels and carries no values. You derive child contexts from parents. Cancellation flows down the tree. If a parent cancels, all children cancel. Values flow down too. A child can add values without affecting the parent.

Context is not a bag for optional parameters. It is for request-scoped data, deadlines, and cancellation. Putting optional arguments in context breaks the contract and makes code harder to read. Use function parameters for optional arguments. Use context for the request lifecycle.

Minimal example

Here is the skeleton: create a context with a timeout, defer the cleanup, and listen for the signal.

package main

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

func main() {
	// Background is the root context. WithTimeout creates a derived context
	// that automatically cancels after the duration.
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	// Always defer cancel to release resources.
	// This releases the timer even if the context isn't cancelled by the deadline.
	defer cancel()

	select {
	case <-time.After(1 * time.Second):
		// The task finishes before the timeout.
		fmt.Println("Task completed")
	case <-ctx.Done():
		// Done() returns a channel that closes when the context ends.
		// Reading from it blocks until cancellation.
		fmt.Println("Context cancelled or timed out:", ctx.Err())
	}
}

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

How the pieces work

context.Background() returns an empty context. It is the starting point for request trees. It implements the Context interface but never cancels and has no values. You derive new contexts from it using helper functions.

context.WithTimeout creates a child context that cancels automatically after the duration. It returns the derived context and a cancel function. You must call the cancel function to release the underlying resources. The convention is to defer cancel() immediately after creation. If you skip the cancel call, the timer keeps running until it fires, which can leak resources if the function returns early.

The Done method returns a channel of type chan struct{}. This channel closes when the context is cancelled. Reading from the channel blocks until the context ends. If the context is already cancelled, the read returns immediately. The struct{} type is empty, so the channel carries no data. It is purely a signal.

The Err method returns a non-nil error after Done is closed. It explains why the context ended. Common errors are context.Canceled and context.DeadlineExceeded. You should check Err after Done closes to distinguish between a manual cancellation and a timeout.

The Deadline method returns the time when the context will expire, and a boolean indicating if a deadline exists. Functions that can block should check the deadline to decide how long to wait. If the deadline is sooner than the operation needs, the function should return early.

Convention aside: the context parameter is always the first argument in a function signature. The community standard name is ctx. Functions that accept a context should respect cancellation and deadlines. If a function spawns a goroutine, it must pass the context to the goroutine so the goroutine can stop when the request ends.

Realistic example

Here is a worker goroutine that runs until the context tells it to stop.

package main

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

// worker runs a loop until the context is cancelled.
// It checks ctx.Done() in the select to respond to cancellation immediately.
func worker(ctx context.Context, id int) {
	for {
		select {
		case <-ctx.Done():
			// Exit the loop immediately when the context is cancelled.
			// This prevents the goroutine from leaking.
			fmt.Printf("Worker %d stopping: %v\n", id, ctx.Err())
			return
		case <-time.After(500 * time.Millisecond):
			// Simulate periodic work.
			fmt.Printf("Worker %d doing work\n", id)
		}
	}
}

func main() {
	// Create a context that cancels after 1.5 seconds.
	ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
	defer cancel()

	// Spawn a worker goroutine.
	// The goroutine receives the context so it can stop when main cancels.
	go worker(ctx, 1)

	// Wait for the context to expire.
	<-ctx.Done()
	fmt.Println("Main done")
}

The worker checks ctx.Done() in a select statement. This makes the loop responsive to cancellation. If the context cancels, the select picks the ctx.Done() case and the function returns. The goroutine exits cleanly. If you remove the ctx.Done() case, the goroutine keeps running until time.After fires, and it won't stop even if the context cancels. That is a goroutine leak.

Context cancellation is cooperative. The context does not kill the goroutine. It signals the goroutine to stop. The goroutine must check the signal and exit. If the goroutine ignores the signal, it leaks. Always check ctx.Done() in long-running loops or before expensive operations.

Passing context to libraries

Many Go libraries accept a context as the first parameter. Database drivers, HTTP clients, and gRPC calls all support context. When you pass a context to a library, the library respects cancellation and deadlines.

If you call db.QueryContext(ctx, ...), the database driver monitors the context. If the context cancels, the driver aborts the query and returns an error. This prevents slow queries from holding connections open.

If you call http.GetWithContext(ctx, url), the HTTP client cancels the request if the context ends. This stops the download and releases the connection.

Always pass the context to library calls. If a library doesn't accept a context, wrap the call in a goroutine that checks the context, or use a version of the library that supports context.

Values in context

Context can carry request-scoped values. Use this for data that belongs to the request, like trace IDs, user IDs, or locale settings. Do not use values for optional parameters. Optional parameters belong in the function signature.

Values are stored as key-value pairs. The key must be comparable. The convention is to use an unexported type for the key to avoid collisions between packages.

package main

import (
	"context"
	"fmt"
)

// traceIDKey is an unexported type to prevent collisions.
// Other packages cannot accidentally use the same key.
type traceIDKey int

const keyTraceID traceIDKey = 0

func main() {
	// Create a context with a trace ID.
	ctx := context.WithValue(context.Background(), keyTraceID, "abc-123")

	// Retrieve the value.
	// The type assertion returns the value and a boolean indicating success.
	if id, ok := ctx.Value(keyTraceID).(string); ok {
		fmt.Println("Trace ID:", id)
	}
}

The unexported type traceIDKey ensures that no other package can define a key with the same underlying value. If you use a simple string or integer as the key, another package might use the same value and overwrite your data. The unexported type makes the key unique to your package.

Retrieving a value requires a type assertion. ctx.Value(key) returns an interface{}. You must assert the type to get the actual value. If the key doesn't exist, Value returns nil. The type assertion returns false in that case.

Convention aside: use WithValue sparingly. Values should be request-scoped metadata. If you find yourself putting many values in context, you are probably using it as a bag. Refactor the design. Pass values as parameters or use a request struct.

Pitfalls and errors

If you pass a value that isn't a context to a function expecting one, the compiler rejects it with cannot use x (type string) as context.Context value in argument. If you forget to import the package, you get undefined: context. If you try to use a method on a nil context, the program panics at runtime. Always use context.Background() or context.TODO() as the root. Never pass nil.

A common mistake is forgetting to call the cancel function. If you create a context with WithTimeout or WithCancel, you must call the cancel function to release resources. If you return from the function without calling cancel, the timer keeps running. This can leak memory and CPU. The convention is to defer cancel() immediately.

Another mistake is using context for optional parameters. Context is for request lifecycle management. Putting optional flags in context makes the API harder to use and breaks the contract. Use function parameters or a config struct for optional arguments.

Goroutine leaks happen when a goroutine waits on a channel that never gets closed, or when a goroutine ignores context cancellation. Always have a cancellation path. Check ctx.Done() in loops. Pass context to goroutines. If a goroutine spawns other goroutines, pass the context down.

Context is not thread-safe for modification. You derive new contexts; you don't mutate existing ones. The WithValue function returns a new context; it doesn't change the parent. This makes context safe to share across goroutines.

Don't use context as a map for optional arguments. Keep the request lifecycle separate from function configuration.

Decision matrix

Use context.Background() when starting a top-level function, a test, or the root of a request tree.

Use context.WithTimeout when an operation must finish within a specific duration, such as a database query or an HTTP call.

Use context.WithCancel when you need manual control over cancellation from another goroutine, such as stopping a background worker when a signal arrives.

Use context.WithDeadline when you have a fixed timestamp rather than a duration, such as synchronizing multiple operations to a common deadline.

Use context.WithValue sparingly to pass request-scoped metadata like trace IDs or user IDs, never for optional parameters.

Use a plain channel when you only need to signal cancellation to a single goroutine and don't need to propagate the signal through a call stack.

Use context.TODO() as a placeholder when you are unsure which context to pass, but you know a context should exist. Replace it with a real context as soon as possible.

Context is the backbone of request lifecycle management. Pass it down, check it often, and cancel responsibly. The worst goroutine bug is the one that never logs.

Where to go next