Context #
The context package is Go’s fundamental mechanism for managing an operation’s lifecycle — cancelling it when it’s no longer needed, giving it a time limit so it doesn’t run forever, and passing values like request IDs or user information across function and goroutine boundaries. Every time you call a database, make an HTTP request to another service, or run a time-consuming operation, there’s always the question: “What happens if the user cancels the request halfway through?” or “What happens if this operation doesn’t finish in 5 seconds?” — the answer is context. Understanding context well is a prerequisite for writing robust Go code, because almost all Go standard library and ecosystem packages that do I/O accept context.Context as their first parameter.
An Overview of the context Package #
flowchart TD
Root["context.Background()\nor context.TODO()"] --> WC["context.WithCancel(parent)"]
Root --> WT["context.WithTimeout(parent, duration)"]
Root --> WD["context.WithDeadline(parent, time)"]
Root --> WV["context.WithValue(parent, key, val)"]
WC --> C1["ctx — the derived context"]
WC --> C2["cancel() — the cancel function"]
WT --> T1["ctx — auto-cancels after the duration"]
WT --> T2["cancel() — cancel earlier"]
WD --> D1["ctx — auto-cancels at the deadline"]
WD --> D2["cancel() — cancel earlier"]
WV --> V1["ctx — carries the key-val value"]
C1 --> Done["ctx.Done() <-chan struct{}\ncancellation signal"]
T1 --> Done
D1 --> Done
Done --> Err["ctx.Err()\ncontext.Canceled\nor context.DeadlineExceeded"]
V1 --> Val["ctx.Value(key)\nget the value"]
style Root fill:#4f86c6,color:#fff
style WC fill:#e8f5e9
style WT fill:#e3f2fd
style WD fill:#fff3e0
style WV fill:#f3e5f5The Four Basic Functions #
Context in Go always starts from a root (Background or TODO) and is extended with With* functions that create a new context derived from the parent.
package main
import (
"context"
"fmt"
)
func main() {
// context.Background — the root context, never cancelled
// Use it as the starting point in main(), tests, and initialization
ctx := context.Background()
// context.TODO — a placeholder when you don't know the right context yet
// Use it when refactoring old code that doesn't use context yet
ctx2 := context.TODO()
_ = ctx
_ = ctx2
// Check whether the context has been cancelled
select {
case <-ctx.Done():
fmt.Println("context cancelled:", ctx.Err())
default:
fmt.Println("context still active")
}
}
WithCancel — Manual Cancellation #
context.WithCancel creates a new context that can be cancelled manually by calling the cancel function. When cancel is called, the ctx.Done() channel is closed and all operations listening on this channel can stop.
import (
"context"
"fmt"
"time"
)
func longOperation(ctx context.Context, name string) error {
select {
case <-time.After(3 * time.Second):
fmt.Printf("%s finished\n", name)
return nil
case <-ctx.Done():
fmt.Printf("%s cancelled: %v\n", name, ctx.Err())
return ctx.Err()
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
// Run several goroutines using the same context
go longOperation(ctx, "goroutine-1")
go longOperation(ctx, "goroutine-2")
go longOperation(ctx, "goroutine-3")
// Cancel everything after 1 second
time.Sleep(time.Second)
cancel() // one call cancels all goroutines listening on ctx
time.Sleep(100 * time.Millisecond) // wait for the goroutines to finish cleanly
fmt.Println("all goroutines finished")
}
// Output:
// goroutine-1 cancelled: context canceled
// goroutine-2 cancelled: context canceled
// goroutine-3 cancelled: context canceled
// all goroutines finished
Cancellation Propagation #
When a parent context is cancelled, all child contexts are cancelled automatically:
flowchart TD
P["Parent Context\ncancel()"] --> C1["Child 1\nWithCancel"]
P --> C2["Child 2\nWithTimeout(5s)"]
C1 --> GC1["Grandchild 1\nWithValue"]
C2 --> GC2["Grandchild 2\nWithCancel"]
Cancel["cancel() called\non the Parent"] --> P
P -- "auto cancels" --> C1
P -- "auto cancels" --> C2
C1 -- "auto cancels" --> GC1
C2 -- "auto cancels" --> GC2
style Cancel fill:#fce4ec
style P fill:#ffcdd2
style C1 fill:#ffcdd2
style C2 fill:#ffcdd2
style GC1 fill:#ffcdd2
style GC2 fill:#ffcdd2func main() {
parent, cancelParent := context.WithCancel(context.Background())
defer cancelParent()
// A child inherits cancellation from the parent
child, cancelChild := context.WithCancel(parent)
defer cancelChild()
// A grandchild inherits from the child
grandchild, cancelGrandchild := context.WithTimeout(child, 10*time.Second)
defer cancelGrandchild()
// Cancel the parent — the child and grandchild are cancelled too
cancelParent()
fmt.Println(parent.Err()) // context canceled
fmt.Println(child.Err()) // context canceled
fmt.Println(grandchild.Err()) // context canceled
// The reverse: cancelling the child does NOT affect the parent
cancelChild()
fmt.Println(parent.Err()) // nil — the parent is still active
}
Always callcancel()returned byWithCancel,WithTimeout, andWithDeadline— usually withdefer cancel(). If not called, the context and all related resources (internal timers, channels) won’t be released until the parent context is cancelled. This is the most common source of goroutine and memory leaks related to context.
WithTimeout — Relative Time Limits #
context.WithTimeout creates a context that’s automatically cancelled after a certain duration from now. This is the most commonly used for operations with time limits.
import (
"context"
"database/sql"
"fmt"
"time"
)
func queryWithTimeout(db *sql.DB, id int) (*User, error) {
// This context is automatically cancelled after 5 seconds
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() // REQUIRED: release the timer resources if finishing early
var u User
err := db.QueryRowContext(ctx,
"SELECT id, name, email FROM users WHERE id = $1", id,
).Scan(&u.ID, &u.Name, &u.Email)
if err != nil {
if err == context.DeadlineExceeded {
return nil, fmt.Errorf("query timed out after 5 seconds")
}
return nil, fmt.Errorf("queryWithTimeout: %w", err)
}
return &u, nil
}
Passing Context from the Caller #
Context should flow from top to bottom — from handler to service to repository, not created anew at each layer:
sequenceDiagram
participant Client as HTTP Client
participant Handler as HTTP Handler
participant Service as Service Layer
participant Repo as Repository
participant DB as Database
Client->>Handler: HTTP Request
Handler->>Handler: ctx = r.Context()\n(already has the server timeout)
Handler->>Service: ProcessData(ctx, input)
Service->>Service: ctx, cancel = WithTimeout(ctx, 3s)\n(add a stricter timeout)
Service->>Repo: FindData(ctx, id)
Repo->>DB: QueryContext(ctx, query)
DB-->>Repo: result / timeout
Repo-->>Service: data / error
Service->>Service: cancel()
Service-->>Handler: result / error
Handler-->>Client: HTTP Response
Note over Handler,DB: If the client disconnects, r.Context() is cancelled\nwhich automatically cancels the DB query// Handler — the context comes from the request
func processOrderHandler(w http.ResponseWriter, r *http.Request) {
// r.Context() is automatically cancelled when:
// 1. The client disconnects
// 2. The server times out (if configured)
ctx := r.Context()
result, err := serviceProcess(ctx, order)
if err != nil {
if errors.Is(err, context.Canceled) {
// The client already disconnected — no need to send a response
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(result)
}
// Service — accept the context from the caller, can add a stricter timeout
func serviceProcess(ctx context.Context, order Order) (*Result, error) {
// Add a stricter timeout for this specific operation
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
// Pass the context to all downstream operations
if err := repoCheckStock(ctx, order.ProductID); err != nil {
return nil, fmt.Errorf("serviceProcess: check stock: %w", err)
}
if err := repoCreateOrder(ctx, order); err != nil {
return nil, fmt.Errorf("serviceProcess: create order: %w", err)
}
return &Result{Status: "success"}, nil
}
// Repository — accept and use the context for all I/O
func repoCheckStock(ctx context.Context, productID int) error {
var stock int
err := db.QueryRowContext(ctx,
"SELECT stock FROM products WHERE id = $1", productID,
).Scan(&stock)
if err != nil {
return fmt.Errorf("repoCheckStock: %w", err)
}
if stock == 0 {
return ErrOutOfStock
}
return nil
}
WithDeadline — Absolute Time Limits #
context.WithDeadline is similar to WithTimeout, but uses an absolute time instead of a relative duration. Useful when you have a fixed deadline that must be met.
import (
"context"
"fmt"
"time"
)
func processBeforeMidnight(ctx context.Context) error {
// Deadline: midnight today
now := time.Now()
midnight := time.Date(
now.Year(), now.Month(), now.Day()+1,
0, 0, 0, 0, now.Location(),
)
ctx, cancel := context.WithDeadline(ctx, midnight)
defer cancel()
fmt.Printf("The process must finish before %v\n",
midnight.Format("15:04:05"))
fmt.Printf("Remaining time: %v\n", time.Until(midnight).Round(time.Second))
// Check how much time is left
deadline, exists := ctx.Deadline()
if exists {
fmt.Println("Deadline:", deadline)
fmt.Println("Remaining:", time.Until(deadline).Round(time.Second))
}
// Run the process...
select {
case <-time.After(2 * time.Hour): // the process takes 2 hours
return nil
case <-ctx.Done():
return fmt.Errorf("process cancelled: %w", ctx.Err())
}
}
// WithDeadline vs WithTimeout
func comparison() {
// Both are equivalent:
ctx1, cancel1 := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel1()
ctx2, cancel2 := context.WithDeadline(context.Background(),
time.Now().Add(5*time.Second))
defer cancel2()
// ctx1 and ctx2 behave identically
_ = ctx1
_ = ctx2
}
WithValue — Passing Values #
context.WithValue stores a key-value pair in the context. This value can be retrieved in any function receiving that context — useful for request IDs, user information, and data that needs to cross many layers without being passed as explicit parameters.
// IMPORTANT: always use a custom type for keys, NOT a plain string
// This prevents collisions with other packages that might use the same key
type contextKey string
const (
KeyRequestID contextKey = "request_id"
KeyUser contextKey = "user"
KeyTraceID contextKey = "trace_id"
)
// Middleware: store the request ID in the context
func requestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Take it from the header or create a new one
requestID := r.Header.Get("X-Request-ID")
if requestID == "" {
requestID = generateRequestID()
}
// Store it in the context
ctx := context.WithValue(r.Context(), KeyRequestID, requestID)
// Include it in the response header for tracing
w.Header().Set("X-Request-ID", requestID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Helper: get the request ID from the context
func getRequestID(ctx context.Context) string {
if id, ok := ctx.Value(KeyRequestID).(string); ok {
return id
}
return "unknown"
}
// Usage in a handler or service
func createProductHandler(w http.ResponseWriter, r *http.Request) {
requestID := getRequestID(r.Context())
log.Printf("[%s] processing create product request", requestID)
// Pass the context to the lower layer — the request ID goes with it
if err := serviceCreateProduct(r.Context(), input); err != nil {
log.Printf("[%s] failed to create product: %v", requestID, err)
http.Error(w, "failed", http.StatusInternalServerError)
return
}
}
func serviceCreateProduct(ctx context.Context, input ProductInput) error {
requestID := getRequestID(ctx) // still accessible!
log.Printf("[%s] service: validating input", requestID)
// ...
return nil
}
What Can and Can’t Go in WithValue #
flowchart LR
subgraph Allowed["✓ OK to store in the context"]
B1["Request IDs / Trace IDs\nfor logging and tracing"]
B2["Authenticated user info\nfrom the auth middleware"]
B3["Language / Locale\nfor internationalization"]
B4["Database transactions\nfor atomic operations"]
end
subgraph NotAllowed["✗ Don't store in the context"]
J1["Optional function parameters\nuse explicit parameters"]
J2["Global configuration\nuse dependency injection"]
J3["Large data\nuse structs or parameters"]
J4["Errors\nreturn them as return values"]
end
style Allowed fill:#e8f5e9
style NotAllowed fill:#fce4ec// ANTI-PATTERN: use context to pass function parameters
func processData(ctx context.Context) error {
// Getting "limit" from the context makes the function unclear
// The caller doesn't know this function needs "limit"
limit, _ := ctx.Value("limit").(int)
_ = limit
return nil
}
// CORRECT: explicit parameters are clearer and easier to test
func processDataGood(ctx context.Context, limit int) error {
_ = limit
return nil
}
// ANTI-PATTERN: use a plain string as the key
ctx := context.WithValue(ctx, "user_id", 42)
// Another package could use the same "user_id" key → collision!
// CORRECT: a custom type as the key
type appContextKey string
ctx = context.WithValue(ctx, appContextKey("user_id"), 42)
ctx.Done() — Listening for Cancellation #
ctx.Done() returns a channel that’s closed when the context is cancelled. This is the main way to respond to cancellation in long-running goroutines.
// Pattern: a cancellable worker
func worker(ctx context.Context, id int, jobs <-chan string) {
for {
select {
case <-ctx.Done():
fmt.Printf("worker %d stopping: %v\n", id, ctx.Err())
return
case job, ok := <-jobs:
if !ok {
fmt.Printf("worker %d: channel closed\n", id)
return
}
processJob(ctx, job)
}
}
}
// Pattern: a cancellable periodic operation
func periodicPolling(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
fmt.Println("polling stopped:", ctx.Err())
return
case <-ticker.C:
if err := checkStatus(ctx); err != nil {
if errors.Is(err, context.Canceled) ||
errors.Is(err, context.DeadlineExceeded) {
return
}
fmt.Println("error while polling:", err)
}
}
}
}
// Pattern: waiting for several goroutines with context
func runParallel(ctx context.Context, tasks []Task) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
errChan := make(chan error, len(tasks))
for _, t := range tasks {
go func(task Task) {
errChan <- task.Run(ctx)
}(t)
}
// Wait for all to finish or one to fail
for range tasks {
if err := <-errChan; err != nil {
cancel() // cancel all the other goroutines
return err
}
}
return nil
}
Checking the Cancellation Type #
When a context is cancelled, ctx.Err() returns one of two errors: context.Canceled (cancelled manually) or context.DeadlineExceeded (timeout). Distinguishing them is important for proper logging and error handling.
flowchart TD
Cancel["ctx.Err()"] --> CE["context.Canceled\n(cancel() called manually)"]
Cancel --> DE["context.DeadlineExceeded\n(timeout or deadline reached)"]
CE --> CEAction["Log as INFO\nusually because the client disconnected\nor the operation was deliberately cancelled"]
DE --> DEAction["Log as WARNING\nthe operation was too slow\nconsider raising the timeout"]
style CE fill:#e3f2fd
style DE fill:#fff3e0
style CEAction fill:#e3f2fd
style DEAction fill:#fff3e0func handleContextError(err error) {
switch {
case err == nil:
return
case errors.Is(err, context.Canceled):
// Cancelled manually — usually because the client disconnected
// Log as info, not as an error
log.Printf("operation cancelled (client may have disconnected)")
case errors.Is(err, context.DeadlineExceeded):
// Timeout — the operation was too slow
// Log as a warning, may need investigation
log.Printf("operation timed out — consider raising the timeout or optimizing the query")
default:
// Other errors
log.Printf("operation error: %v", err)
}
}
// In an HTTP handler — distinguish cancellation from real errors
func longHandler(w http.ResponseWriter, r *http.Request) {
result, err := longOperation(r.Context())
if err != nil {
if errors.Is(err, context.Canceled) {
// Client disconnected — no need to send a response
log.Printf("client disconnected for %s", r.URL.Path)
return
}
if errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "request timeout", http.StatusGatewayTimeout)
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(result)
}
context.WithoutCancel (Go 1.21+) #
Since Go 1.21, there’s context.WithoutCancel, which creates a new context that forwards the parent’s values but doesn’t inherit its cancellation. Useful for cleanup operations that must keep running even after the main request has been cancelled.
func createOrderHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Create the order — can be cancelled if the client disconnects
order, err := serviceCreateOrder(ctx, input)
if err != nil {
if errors.Is(err, context.Canceled) {
return // client disconnected before the order was created
}
http.Error(w, "failed", 500)
return
}
// Send the notification — MUST finish even if the client disconnects
// Use a context without the request's cancellation
ctxNotif := context.WithoutCancel(ctx) // Go 1.21+
go func() {
if err := sendNotification(ctxNotif, order); err != nil {
log.Printf("failed to send notification for order %d: %v", order.ID, err)
}
}()
json.NewEncoder(w).Encode(order)
}
Production Usage Patterns #
Context for Database Transactions #
type contextKey string
const keyDBTx contextKey = "db_tx"
// Store the transaction in the context for cross-function atomic operations
func withTransaction(ctx context.Context, db *sql.DB,
fn func(context.Context) error) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
// Store the tx in the context
ctx = context.WithValue(ctx, keyDBTx, tx)
if err := fn(ctx); err != nil {
// Roll back if the function fails
if rbErr := tx.Rollback(); rbErr != nil {
return fmt.Errorf("rollback failed: %v (original error: %w)", rbErr, err)
}
return err
}
return tx.Commit()
}
// Get the tx from the context or use the regular db
func getDB(ctx context.Context, db *sql.DB) interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
ExecContext(context.Context, string, ...any) (sql.Result, error)
} {
if tx, ok := ctx.Value(keyDBTx).(*sql.Tx); ok {
return tx
}
return db
}
// Usage
func serviceMoveStock(ctx context.Context, from, to, productID, quantity int) error {
return withTransaction(ctx, db, func(ctx context.Context) error {
// Both operations are in one transaction
if err := repoDecreaseStock(ctx, from, productID, quantity); err != nil {
return err
}
if err := repoIncreaseStock(ctx, to, productID, quantity); err != nil {
return err
}
return nil
})
}
Nested Timeouts with Context #
// Each layer can add a stricter timeout than the parent
func apiHandler(w http.ResponseWriter, r *http.Request) {
// The server already has a timeout from http.Server.WriteTimeout (e.g. 30 seconds)
ctx := r.Context()
// The handler adds a total request limit: 20 seconds
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
result, err := serviceProcess(ctx, r)
// ...
_ = result
_ = err
}
func serviceProcess(ctx context.Context, r *http.Request) (*Result, error) {
// The service adds a stricter timeout for DB operations: 5 seconds
dbCtx, dbCancel := context.WithTimeout(ctx, 5*time.Second)
defer dbCancel()
data, err := repoFetchData(dbCtx)
if err != nil {
return nil, err
}
// A different timeout for external API calls: 3 seconds
apiCtx, apiCancel := context.WithTimeout(ctx, 3*time.Second)
defer apiCancel()
enriched, err := externalAPI(apiCtx, data)
if err != nil {
return nil, err
}
return process(enriched), nil
}
Request ID Propagation for Distributed Tracing #
// Middleware adding trace context
func tracingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
traceID := r.Header.Get("X-Trace-ID")
if traceID == "" {
traceID = newTraceID()
}
spanID := newSpanID()
ctx := r.Context()
ctx = context.WithValue(ctx, KeyTraceID, traceID)
ctx = context.WithValue(ctx, contextKey("span_id"), spanID)
w.Header().Set("X-Trace-ID", traceID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// A logger that automatically includes trace info
type ContextLogger struct {
logger *log.Logger
}
func (l *ContextLogger) Info(ctx context.Context, format string, args ...any) {
traceID := getStringValue(ctx, KeyTraceID)
spanID := getStringValue(ctx, contextKey("span_id"))
prefix := fmt.Sprintf("[trace:%s span:%s] ", traceID, spanID)
l.logger.Printf(prefix+format, args...)
}
func getStringValue(ctx context.Context, key contextKey) string {
if v, ok := ctx.Value(key).(string); ok {
return v
}
return "-"
}
Graceful Shutdown with Context #
func main() {
// The application's main context
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Start the server
server := &http.Server{Addr: ":8080", Handler: makeHandler()}
go func() {
if err := server.ListenAndServe(); err != http.ErrServerClosed {
log.Printf("server error: %v", err)
cancel() // cancel the main context if the server crashes
}
}()
// Start background jobs
go processQueue(ctx)
go cleanExpiredCache(ctx)
go syncData(ctx)
// Wait for a shutdown signal
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
select {
case sig := <-sigChan:
log.Printf("received signal: %v", sig)
case <-ctx.Done():
log.Printf("main context cancelled")
}
// Start the shutdown
log.Println("starting graceful shutdown...")
cancel() // stop all background jobs
// Give the server time to finish in-flight requests
shutdownCtx, shutdownCancel := context.WithTimeout(
context.Background(), 30*time.Second)
defer shutdownCancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown error: %v", err)
}
log.Println("shutdown complete")
}
When to Switch to Alternatives #
Keep using context if:
✓ Cancellation and timeouts for all I/O operations
✓ Passing request-scoped values (request IDs, users, trace IDs)
✓ Coordinating shutdown across goroutines
✓ Integration with context-aware libraries (database/sql, net/http, etc.)
Consider sync.WaitGroup if:
✗ You only need to wait for a group of goroutines to finish
✗ No cancellation or timeouts needed
✗ Simple coordination without value propagation
Consider plain channels if:
✗ Specific one-way communication between goroutines
✗ No need for cancellation propagation to many goroutines
Consider errgroup (golang.org/x/sync/errgroup) if:
✗ Running several goroutines and collecting the first error
✗ Automatically cancelling everything when one goroutine fails
✗ errgroup.WithContext combines WaitGroup + Context elegantly
Summary #
- Always
defer cancel()afterWithCancel,WithTimeout, orWithDeadline— without it, internal resources (timers, channels) aren’t released until the parent is cancelled, causing goroutine leaks.- Context flows from top to bottom — the handler receives the context from the request (
r.Context()), passes it to the service, the service to the repository, the repository to the database. Don’t create a newcontext.Background()in the middle of the stack.WithTimeoutvsWithDeadline: useWithTimeoutfor relative duration limits (“at most 5 seconds from now”), useWithDeadlinefor absolute time limits (“must finish before midnight”).- Distinguish
context.Canceledandcontext.DeadlineExceededwhen handling errors — the former is usually because the client disconnected (log as info), the latter because the operation was too slow (log as a warning, needs investigation).- Use a custom type for
WithValuekeys, not a plain string — this prevents collisions with other packages that might use the same key.WithValuefor request-scoped data only — request IDs, authenticated users, trace IDs. Don’t use it to pass function parameters; use explicit parameters.context.WithoutCancel(Go 1.21+) for cleanup operations that must finish even if the main request is cancelled — like sending notifications or writing audit logs.- Context-aware libraries:
database/sql,net/http,os/exec, and almost all modern Go libraries accept context — always use the context variants (QueryContext,NewRequestWithContext,CommandContext).ctx.Done()in aselectis the idiomatic way to make goroutines responsive to cancellation — always pair it with other cases handling the actual work.