You've shipped your API to production, and now requests are hanging or crashing unexpectedly. The database connection pool is exhausted, the HTTP client has no timeout, and there's no way to cancel work that's no longer needed. This is where context.Context in Go: a practical guide for developers becomes essential. Context isn't just a convention—it's the mechanism that makes cancellation, deadlines, and request-scoped values work reliably across your entire stack.
Why context matters in production APIs
Before we dive into the mechanics, let's understand the problem we're solving. In a typical request lifecycle, your handler might spawn multiple goroutines, call a database, hit an external API, and perform some background work. If the client disconnects or a deadline passes, you want all of that work to stop immediately. Without context, your goroutines keep running, consuming resources and holding database connections open.
Context propagates cancellation signals and deadlines through the call chain. It's not a magic fix, but it's the idiomatic Go way to manage lifecycle and resources in concurrent systems.
Pass it first — ctx context.Context is the first parameter by convention
The first rule of context in Go is simple: always pass context as the first parameter to functions that might block or make network calls. This isn't arbitrary—it's a convention that the entire Go standard library follows.
func FetchUser(ctx context.Context, userID string) (*User, error) {
// Placeholder implementation
return nil, nil
}
func ProcessOrder(ctx context.Context, orderID string) error {
// Placeholder implementation
return nil
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// The request context is already available
ctx := r.Context()
user, err := FetchUser(ctx, "123")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := ProcessOrder(ctx, "order-456"); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}Notice that we didn't create a new context in the handler. The http.Request already has one attached via r.Context(). Pass it down the chain. When the client closes the connection, the HTTP server automatically cancels the context, and all downstream operations can react to that cancellation.
If you're building internal services that don't receive HTTP requests, start with context.Background() as your root context, then derive child contexts from there.
Deadlines — Stop work when the client is gone
Deadlines are how context enforces time limits. When a deadline arrives, context signals cancellation and any subsequent operations using that context will fail fast.
There are two ways to add a deadline. context.WithTimeout sets a deadline relative to now, and context.WithDeadline sets an absolute deadline. In practice, you'll use WithTimeout most of the time:
func (h *Handler) handleRequest(w http.ResponseWriter, r *http.Request) {
// Give the entire operation 2 seconds
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
// Fetch from database
user, err := h.db.GetUser(ctx, userID)
if err != nil {
// If ctx exceeded the deadline, err will be context.DeadlineExceeded
if errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "Request timeout", http.StatusGatewayTimeout)
} else {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}The pattern is always the same: call WithTimeout or WithDeadline, immediately defer the cancel function, then use the derived context for all downstream work. The defer cancel() ensures the context is cleaned up and resources are released, even if we return early or panic.
Here's why this matters. Without a deadline, a slow database query or external API can block your handler indefinitely. Your HTTP server will eventually run out of goroutines, new requests will queue, and the entire service degrades. With a deadline, the database driver or HTTP client will check ctx.Done() and return early if time has expired.
Propagate — Pass ctx to database and HTTP calls
Context only works if you pass it everywhere. The Go standard library and most popular packages check context in their blocking operations. Your database driver, HTTP client, and custom functions should all accept context as the first parameter.
type UserRepository interface {
GetUser(ctx context.Context, id string) (*User, error)
CreateUser(ctx context.Context, user *User) error
}
type Service struct {
db UserRepository
httpClient *http.Client
}
func (s *Service) FetchAndNotify(ctx context.Context, userID string) error {
// GetUser respects ctx timeouts and cancellation
user, err := s.db.GetUser(ctx, userID)
if err != nil {
return fmt.Errorf("database error: %w", err)
}
// The HTTP request also respects ctx
notifyReq, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
"https://notify.example.com/send",
bytes.NewReader([]byte(`{"id":"` + user.ID + `"}`)),
)
if err != nil {
return err
}
resp, err := s.httpClient.Do(notifyReq)
if err != nil {
// If ctx was cancelled or deadline exceeded, err will reflect that
return fmt.Errorf("notification failed: %w", err)
}
defer resp.Body.Close()
return nil
}This is critical: use http.NewRequestWithContext, not http.NewRequest. The difference is that the context-aware version will cancel the request if the context deadline expires or is cancelled mid-flight.
The same applies to database calls. PostgreSQL drivers, MongoDB clients, and others all take context as the first parameter. If they don't, that's a red flag—it means the library can't respect cancellation or timeouts, which is a serious problem in production.
Values sparingly — Only request-scoped data like trace IDs
Context also carries values. You can attach request-scoped data like trace IDs, user IDs, or tenant information using context.WithValue. However, this feature is easy to abuse.
// Define a custom key type to avoid collisions
type ctxKeyTraceID string
const traceIDKey ctxKeyTraceID = "trace-id"
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
traceID := generateTraceID()
// Attach trace ID to context
ctx := context.WithValue(r.Context(), traceIDKey, traceID)
// It flows through the call chain
user, err := h.fetchUser(ctx, "123")
if err != nil {
h.log.WithField("trace_id", traceID).Error(err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
w.Header().Set("X-Trace-ID", traceID)
w.WriteHeader(http.StatusOK)
}
func (h *Handler) fetchUser(ctx context.Context, id string) (*User, error) {
traceID, ok := ctx.Value(traceIDKey).(string)
if ok {
h.log.WithField("trace_id", traceID).Debug("Fetching user")
}
return h.db.GetUser(ctx, id)
}The key insight: context values are for metadata, not for passing function arguments. Don't use context to avoid threading parameters through your call stack. If a function needs data, take it as an explicit parameter. Context values should be orthogonal to the function's logic—things like trace IDs, request IDs, or tenant information that you want to propagate for logging and observability without cluttering signatures.
Always use a custom key type (not a string) to prevent collisions from different packages writing to the same context. The empty struct pattern is idiomatic and costs nothing:
type contextKey struct{}
var userKey contextKey
ctx = context.WithValue(r.Context(), userKey, user)Common mistakes
- Forgetting to defer cancel(). When you call
WithTimeout,WithDeadline, orWithCancel, you get a cancel function. Not calling it leaks the goroutine that manages the timeout. Alwaysdefer cancel()immediately after creation. - Creating a new context for each operation. Don't do this:
go
for i := 0; i < 100; i++ { // WRONG: Each iteration gets its own 1-second timeout ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() // Do work... } // RIGHT: Set timeout once, reuse for all operations ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() for i := 0; i < 100; i++ { // All iterations share the same 5-second budget } - Using context for general configuration. Context isn't a dependency injection container. Don't stash your logger, database, or configuration in context values. Pass them as explicit parameters or store them on structs.
- Ignoring
context.DeadlineExceeded. When a deadline passes,ctx.Done()closes and subsequent I/O operations fail. Check forcontext.DeadlineExceededexplicitly so you can return appropriate HTTP status codes (e.g., 504 Gateway Timeout instead of 500). - Not checking
ctx.Done()in long-running operations. If your function does CPU-intensive work, periodically checkselect { case <-ctx.Done(): return ctx.Err() default: }` to exit early if cancellation is requested.
Key takeaways
- Always pass
context.Contextas the first parameter to functions that block or make network calls. This is a Go convention and enables cancellation and deadlines. - Use
context.WithTimeoutto enforce time limits on operations. Pair it withdefer cancel()immediately to avoid leaking goroutines. - Pass context down the entire call chain to your database, HTTP clients, and custom functions. Libraries respect context, so cancellation flows automatically.
- Attach only request-scoped metadata to context values (trace IDs, user IDs, request IDs). Don't use context as a workaround for threading parameters through your code.
- Check for
context.DeadlineExceededin error handling so you can return appropriate HTTP status codes and log meaningful messages. - In long-running operations or loops, periodically check
ctx.Done()and exit early if the context is cancelled. - Context is not optional in production APIs—it's how you prevent resource leaks, enforce timeouts, and make your service resilient to cascading failures.






Comments
Loading comments…