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:#f3e5f5

The 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:#ffcdd2
func 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 call cancel() returned by WithCancel, WithTimeout, and WithDeadline — usually with defer 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:#fff3e0
func 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() after WithCancel, WithTimeout, or WithDeadline — 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 new context.Background() in the middle of the stack.
  • WithTimeout vs WithDeadline: use WithTimeout for relative duration limits (“at most 5 seconds from now”), use WithDeadline for absolute time limits (“must finish before midnight”).
  • Distinguish context.Canceled and context.DeadlineExceeded when 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 WithValue keys, not a plain string — this prevents collisions with other packages that might use the same key.
  • WithValue for 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 a select is the idiomatic way to make goroutines responsive to cancellation — always pair it with other cases handling the actual work.

← Previous: Net Http   Next: Sync →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact