Modern Go applications handle many operations at the same time like HTTP requests, background tasks, database queries, and external services. Because of this, it is important to control how long each task runs and to stop work that is no longer needed.
However, without a shared approach, cancellation and time limits can be handled in its own way, then the code becomes harder to manage and wastes resources. Thus, Go provides a standard mechanism to handle these concerns across function boundaries by Golang Context package.
This blog walks through how context is used in real Go code, with practical examples for HTTP, database queries, and background work. Continue reading to see common patterns and mistakes you should avoid when building Go services.
What is Context in Golang?
Go’s context package is a standard mechanism for controlling cancellation, timeouts, and request-scoped data across goroutines in modern Go services, especially around HTTP requests and database operations. It provides a single, consistent way to signal when work should stop and to limit how long operations are allowed to run.
Without golang context, each library would roll its own cancellation solution, leading to fragmented patterns, harder debugging, and subtle resource leaks. By standardizing on context.Context, Go makes cooperative cancellation and time‑bounded operations part of the language’s idioms for HTTP request handling and database access.
Example (no context, anti‑pattern):
func handle(w http.ResponseWriter, r *http.Request) {
// No context usage, work keeps running even if client disconnects.
time.Sleep(10 * time.Second)
w.Write([]byte("done"))
}
This handler will keep sleeping even if the client closes the connection, wasting resources.
Understanding the Context Interface
The Context interface exposes Deadline, Done, Err, and Value, giving enough information to coordinate cancellation and deadlines. It is safe for concurrent use and is designed to be passed down call chains rather than stored for later.
You almost never implement Context yourself, instead, you use the factory functions (WithCancel, WithTimeout, WithDeadline, WithValue) provided by the context package. This keeps behavior consistent and interoperable across different libraries and frameworks.
Example: inspecting ctx in a helper function
func doWork(ctx context.Context) error {
if deadline, ok := ctx.Deadline(); ok {
log.Printf("Deadline set at: %v", deadline)
} else {
log.Println("No deadline set")
}
select {
case <-time.After(2 * time.Second):
// Simulate work.
return nil
case <-ctx.Done():
// React to cancellation.
return ctx.Err()
}
}
Root Contexts: Golang Context Background and TODO
Root contexts are the starting points of a context tree: context.Background() and context.TODO(). They are never canceled and do not carry deadlines or values, but they are used to bootstrap context usage at system boundaries.
- context.Background is the correct root for main, init, and top‑level tests.
- context.TODO is a placeholder when you know a context is needed but have not yet decided the right parent.
In HTTP handlers you normally do not create these directly because the request already provides a context, but they are important in command‑line tools, background jobs, or when kicking off long‑running processes.
Example: CLI tool using Background
func main() {
ctx := context.Background()
if err := run(ctx); err != nil {
log.Fatal(err)
}
}
func run(ctx context.Context) error {
// Further derivations happen here, e.g. WithTimeout.
return doWork(ctx)
}
Derived Contexts: WithCancel, WithTimeout, WithDeadline
Derived contexts add cancellation and timeouts scoped to a subtree of operations. The function that creates a derived context is responsible for calling cancel to release resources.
- WithCancel(ctx) returns a child context and a cancel function you must call exactly once;
- WithTimeout(ctx, d) cancels after a duration;
- WithDeadline(ctx, t) cancels at a specific time.
A critical best practice is to always defer cancel immediately after creating the context to avoid leaks. Even when you “know” the timeout will fire, cancel frees timers and internal state earlier.
Example: Canceling work explicitly
func doWithCancel(parent context.Context) error {
ctx, cancel := context.WithCancel(parent)
defer cancel() // Always defer.
ch := make(chan error, 1)
go func() {
// Simulated long work.
time.Sleep(5 * time.Second)
ch <- nil
}()
// Cancel after 1 second for demonstration.
time.AfterFunc(1*time.Second, cancel)
select {
case err := <-ch:
return err
case <-ctx.Done():
return ctx.Err()
}
}
Example: Using WithTimeout for bounded operations
func doWithTimeout(parent context.Context) error {
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel() // Important.
select {
case <-time.After(5 * time.Second):
// Would finish too late.
return nil
case <-ctx.Done():
return ctx.Err() // Likely context.DeadlineExceeded.
}
}
Using Context in HTTP Handlers (Incoming Requests)
The net/http package binds a context to every *http.Request through r.Context(). This context is canceled when the client disconnects, the handler returns, or a server‑defined timeout is reached.
Best practice is to treat req.Context() as the root context for all work related to that request. Pass it to any goroutines, database calls, or external services so they can react to cancellation and avoid doing work for abandoned requests.
Example: HTTP handler using context
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Simulated slow work respecting context.
if err := doWork(ctx); err != nil {
if errors.Is(err, context.Canceled) {
log.Println("request canceled by client")
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Write([]byte("OK"))
}
func doWork(ctx context.Context) error {
select {
case <-time.After(5 * time.Second):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
Here, if the client closes the connection before 5 seconds, ctx.Done() is triggered and the work stops early.
Outbound HTTP Requests with Context
When your service calls other services, http.NewRequestWithContext or req.WithContext(ctx) attaches a context to the outbound request. If the context is canceled or its deadline exceeds, the HTTP client will abort the request and return an error consistent with the context state.
A common pattern is to derive a tighter timeout for each external call from the incoming request’s context. This ensures that if the original HTTP request is canceled, all outbound calls cancel too, but each dependency still has its own latency budget.
Example: Outbound call with per‑call timeout
func callExternalAPI(ctx context.Context, client *http.Client, url string) ([]byte, error) {
// Derive a 2‑second timeout from the incoming context.
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err // Could be due to ctx timeout.
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
This pattern integrates cleanly into higher‑level HTTP handlers by passing r.Context() into callExternalAPI.
Database Operations with context.Context
The database/sql package exposes QueryContext, ExecContext, and PrepareContext, which accept context.Context as the first argument. This allows queries and commands to be bounded by deadlines and to be canceled when requests or upstream operations end.
Using golang database context properly reduces the chance of long‑running queries tying up connections unnecessarily. Drivers that support context should check ctx.Done() and attempt to interrupt queries or close connections when cancellation is signaled.
Example: Query with request context
func getUser(ctx context.Context, db *sql.DB, id int64) (*User, error) {
const query = `SELECT id, name, email FROM users WHERE id = ?`
row := db.QueryRowContext(ctx, query, id)
var u User
if err := row.Scan(&u.ID, &u.Name, &u.Email); err != nil {
return nil, err
}
return &u, nil
}
Example: Query with stricter timeout than request
func getUserFast(ctx context.Context, db *sql.DB, id int64) (*User, error) {
ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()
const query = `SELECT id, name, email FROM users WHERE id = ?`
row := db.QueryRowContext(ctx, query, id)
var u User
if err := row.Scan(&u.ID, &u.Name, &u.Email); err != nil {
// If timeout, err may wrap context.DeadlineExceeded.
return nil, err
}
return &u, nil
}
Combining these patterns with HTTP request context means database calls will stop when clients disconnect or when timeouts are exceeded.
>> Explore further:
- How to Work with PostgreSQL in Golang using pgx Package?
- How to Use SQLite in Go? Setup, Patterns, and Performance
Using Context Values Safely
context.WithValue lets you attach key‑value pairs to a context for request‑scoped metadata. Typical uses include correlation IDs, user information, and feature flags that really belong to the lifetime of that specific HTTP request.
However, values should be small and immutable, and context should not be treated as a generic bag of dependencies. Putting large or core dependencies like *sql.DB or configuration in context reduces clarity and makes APIs harder to understand and test.
Example: Storing a correlation ID
type ctxKeyCorrelationID struct{}
func WithCorrelationID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, ctxKeyCorrelationID{}, id)
}
func CorrelationID(ctx context.Context) (string, bool) {
v := ctx.Value(ctxKeyCorrelationID{})
id, ok := v.(string)
return id, ok
}
Using it in an HTTP handler:
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := r.Header.Get("X-Correlation-ID")
if id == "" {
id = uuid.NewString()
}
ctx = WithCorrelationID(ctx, id)
logWithCorrelation(ctx, "handling request")
// Pass ctx down to further calls.
}
func logWithCorrelation(ctx context.Context, msg string) {
if id, ok := CorrelationID(ctx); ok {
log.Printf("[corr_id=%s] %s", id, msg)
return
}
log.Println(msg)
}
This keeps the correlation ID logically tied to the request’s context while avoiding global state.
Function Signatures and Propagation Best Practices
The Go community has converged on conventions for where and how to use context.
- Any function that performs I/O, blocks, or spawns goroutines should accept context.Context as its first parameter (named ctx).
- Pure, short, CPU‑only functions usually do not include context.
- Contexts should be passed down the call stack, not stored on structs or reused across unrelated operations.
Code becomes easier to reason about: when you read a signature func (s *Service) DoSomething(ctx context.Context, req *Request) error, you know immediately that cancellation, timeouts, and request‑scoped metadata are involved.
Example: Propagation through layers
// HTTP handler layer.
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var input CreateUserInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
user, err := h.service.CreateUser(ctx, input)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(user)
}
// Service layer.
func (s *Service) CreateUser(ctx context.Context, in CreateUserInput) (*User, error) {
// Business logic here.
return s.repo.InsertUser(ctx, in)
}
// Repository layer.
func (r *Repo) InsertUser(ctx context.Context, in CreateUserInput) (*User, error) {
// Uses ctx for DB operations.
return insertUserDB(ctx, r.db, in)
}
Every layer can participate in cancellation, timeouts, and logging via the same ctx.
Common Pitfalls and Modern Tooling
Some mistakes show up repeatedly in real Go code:
- Forgetting to call cancel after WithCancel/WithTimeout/WithDeadline, leading to leaked timers and goroutines.
- Using context.Background deep in the stack instead of propagating the caller’s context, effectively disabling cancellation.
- Misusing WithValue as a general data store.
Modern tooling helps. For example, gopls includes a lostcancel analyzer that detects contexts whose cancel function is never called. Combined with code reviews and clear team guidelines, this keeps context usage consistent across large codebases.
Example: lostcancel pattern (do NOT do this)
func bad(ctx context.Context) error {
ctx, _ = context.WithTimeout(ctx, time.Second)
// Missing cancel() call here – analyzer will warn.
return doWork(ctx)
}
Closing Thoughts
Go’s context package is central to writing resilient HTTP and database services that manage timeouts, cancellations, and request‑scoped metadata cleanly. By following idiomatic patterns like propagating request context, choosing proper deadlines, using golang database context methods, and avoiding misuse of WithValue, you make your services more predictable, observable, and resource‑efficient.
As Go continues to evolve in versions 1.21 and beyond, context remains the standard mechanism for coordinating concurrent work, especially in networked and microservice environments. Mastering go http context and database/sql context patterns is therefore essential for anyone building production services in Go today.
>>> Follow and Contact Relia Software for more information!
- golang
- coding
