The Problem with context.Background() in Payment Code
If you've worked on payment systems in Go, you've probably seen this pattern everywhere:
// DON'T do this in payment pipelines
func ChargeCard(amount int64, cardToken string) (*ChargeResult, error) {
ctx := context.Background() // ← This is the problem
row := db.QueryRowContext(ctx, "SELECT * FROM merchants WHERE token = $1", cardToken)
// ...
resp, err := paymentGateway.Charge(ctx, &ChargeRequest{Amount: amount})
// ...
}
On the surface it looks harmless. The function needs a context, so you create one. But in a payment pipeline, this single line disconnects your database query and gateway call from every safety mechanism upstream — timeouts, cancellation, tracing, and request-scoped metadata all vanish.
When a client disconnects mid-checkout, the HTTP handler's context gets cancelled. But your ChargeCard function doesn't know that. It's running on a fresh context.Background() with no deadline. The gateway call hangs for its full 30-second default timeout. Multiply that by a few hundred concurrent requests during a traffic spike, and you've got a connection pool that's completely exhausted.
How Context Flows Through a Payment Pipeline
A well-structured payment pipeline passes context from the entry point all the way down to the last I/O call. Here's what that flow looks like in practice:
ctx + timeout + traceID
ctx propagated
ctx propagated
ctx propagated
ctx deadline enforced
Each stage inherits the parent context — timeouts, trace IDs, and cancellation signals propagate end-to-end.
Every stage receives the same parent context. If the HTTP request is cancelled at any point, every downstream operation — the fraud check, the gateway charge, the ledger write — gets the cancellation signal immediately. No orphaned work, no wasted connections.
The Right Way: Accept Context, Don't Create It
The fix is straightforward. Every function in your payment pipeline should accept a context.Context as its first parameter and pass it through:
func ChargeCard(ctx context.Context, amount int64, cardToken string) (*ChargeResult, error) {
// Add a stage-specific timeout on top of the parent deadline
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
row := db.QueryRowContext(ctx, "SELECT * FROM merchants WHERE token = $1", cardToken)
// ...
resp, err := paymentGateway.Charge(ctx, &ChargeRequest{Amount: amount})
// ...
}
Now the function respects the caller's deadline. If the parent context has 8 seconds remaining but we set a 5-second timeout here, the effective deadline is 5 seconds. If the parent only has 2 seconds left, the effective deadline is 2 seconds. context.WithTimeout always picks the sooner deadline.
Request-Scoped Values: Trace IDs and Merchant Context
Context isn't just about timeouts. In payment systems, you need request-scoped metadata flowing through every layer — trace IDs for distributed tracing, merchant IDs for multi-tenant isolation, and idempotency keys for safe retries.
type ctxKey string
const (
traceIDKey ctxKey = "trace_id"
merchantIDKey ctxKey = "merchant_id"
idempotencyKey ctxKey = "idempotency_key"
)
// Middleware injects request-scoped values
func PaymentMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, traceIDKey, r.Header.Get("X-Trace-ID"))
ctx = context.WithValue(ctx, merchantIDKey, r.Header.Get("X-Merchant-ID"))
ctx = context.WithValue(ctx, idempotencyKey, r.Header.Get("Idempotency-Key"))
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Deep in the pipeline, values are still available
func recordLedgerEntry(ctx context.Context, entry *LedgerEntry) error {
traceID, _ := ctx.Value(traceIDKey).(string)
merchantID, _ := ctx.Value(merchantIDKey).(string)
log.Info("recording ledger entry",
"trace_id", traceID,
"merchant_id", merchantID,
"amount", entry.Amount,
)
return db.ExecContext(ctx, "INSERT INTO ledger ...")
}
When someone uses context.Background() mid-pipeline, all of this metadata disappears. Your logs lose correlation. Your traces break. And when an incident happens at 2 AM, you're flying blind.
Tip: Use custom unexported types for context keys (like ctxKey above) instead of plain strings. This prevents collisions between packages that might use the same key name. It's a small thing, but in a payment system with dozens of internal packages, it prevents subtle bugs that are painful to track down.
Context-Aware Database Queries
Every database call in a payment service should use the context-aware variants: QueryRowContext, ExecContext, QueryContext. But there's a subtlety that trips people up — what happens when the context is cancelled between the query and reading the result?
func getPaymentStatus(ctx context.Context, paymentID string) (string, error) {
// The context controls both the query execution AND the connection checkout
// from the pool. If ctx is cancelled while waiting for a free connection,
// the call returns immediately instead of blocking.
var status string
err := db.QueryRowContext(ctx,
"SELECT status FROM payments WHERE id = $1", paymentID,
).Scan(&status)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
// Log and return a specific error — the caller needs to know
// this wasn't a DB failure, it was a timeout
return "", fmt.Errorf("payment status query timed out: %w", err)
}
return "", err
}
return status, nil
}
The key insight: db.QueryRowContext uses the context for connection pool checkout, not just query execution. Under load, when your pool is saturated, a proper context with a deadline prevents goroutines from piling up waiting for connections. With context.Background(), those goroutines wait indefinitely.
context.Background() vs Proper Propagation
| Scenario | context.Background() | Propagated Context |
|---|---|---|
| Client disconnects | Work continues, connections held | All downstream ops cancelled |
| Gateway timeout | Waits full default timeout (30s+) | Respects parent deadline |
| Distributed tracing | Trace chain broken, new root span | Full trace from HTTP to DB |
| DB pool under load | Goroutines pile up indefinitely | Goroutines exit on deadline |
| Incident debugging | No request correlation in logs | Full request context in every log line |
| Cost impact | Wasted compute, held connections, potential double-charges | Clean resource release, predictable behavior |
When context.Background() Is Actually Correct
I'm not saying you should never use context.Background(). There are legitimate cases:
- Background workers — A reconciliation job that runs on a cron schedule isn't tied to any HTTP request. Starting with
context.Background()and adding your own timeout is the right call. - Graceful shutdown cleanup — When your service is shutting down and you need to finish in-flight settlements, you might intentionally use a new context with a generous deadline so the work isn't cancelled by the dying request context.
- Application startup — Database migrations, connection pool initialization, config loading — these happen before any request context exists.
The rule is simple: if there's a parent context available, use it. Only reach for context.Background() when you're genuinely starting a new, independent operation.
Warning: If you find yourself writing context.Background() inside an HTTP handler or any function called from one, stop and ask why. Nine times out of ten, it means someone forgot to thread the context through, and the right fix is to add ctx context.Context to the function signature — not to paper over it with a fresh context.
Catching context.Background() in Code Review
We added a simple linting rule to our CI pipeline that flags any use of context.Background() or context.TODO() outside of main(), init(), or test files. It's not perfect — it catches some legitimate uses — but it's started good conversations in code review and caught real bugs before they hit production.
You can also use go vet analyzers or tools like contextcheck from the golangci-lint suite to catch functions that accept a context but don't pass it to downstream calls.
A Practical Checklist
- Every exported function that does I/O should accept
context.Contextas its first parameter. - Use
context.WithTimeoutto add stage-specific deadlines — don't rely solely on the parent timeout. - Always call
defer cancel()immediately after creating a derived context. - Use context-aware database methods (
QueryRowContext,ExecContext) everywhere. - Check
ctx.Err()in error handling to distinguish timeouts from actual failures. - Inject trace IDs and merchant IDs via context values at the middleware layer.
Getting context propagation right isn't glamorous work. But in payment systems, where a leaked goroutine can mean a double-charge and a connection pool exhaustion can mean a full outage during peak traffic, it's some of the highest-leverage code you'll write.
References
- Go standard library — context package documentation
- Go Blog — Go Concurrency Patterns: Context
- Go database/sql — QueryRowContext documentation
- OpenTelemetry Go — Context propagation and span creation
- golangci-lint — contextcheck linter
- Go Blog — Contexts and structs
Disclaimer: This article reflects the author's personal experience and opinions. Product names, logos, and brands are property of their respective owners. Pricing and features mentioned are subject to change — always verify with official documentation.