The slow query that kills your server
You are building a user profile page. The database query takes 200 milliseconds on a good day. One Tuesday, the database gets busy. The query drags on for ten seconds. Your HTTP handler is stuck waiting. The connection pool fills up. New requests start timing out. The whole service grinds to a halt because one slow query refused to give up.
This happens when code blocks without a way to bail out. Go solves this with context.Context. Context gives the caller a way to tell a blocking operation, "Stop working now." It also carries deadlines so operations can self-terminate if they take too long.
Context as a cancellation signal
Think of context.Context like a tripwire. You set up the query, and you also lay down a tripwire that says, "If five seconds pass without a result, cut the power." The database driver watches that tripwire. When it trips, the driver aborts the query and returns control to your code immediately.
Context also propagates. If you pass a context to a function, that function can pass it to other functions. If the context cancels, the signal ripples down the call stack. Every function that respects the context sees the cancellation and stops.
By convention, context.Context is always the first parameter to any function that might block or do I/O. The community names it ctx. This makes it obvious at a glance that the function respects cancellation. Functions that take a context should respect cancellation and deadlines. If a function ignores the context, it defeats the purpose of the plumbing.
Minimal example: timeout and cleanup
Here is the simplest pattern: create a context with a timeout, pass it to the query, and handle the result.
// QueryUser retrieves a user name with a timeout.
func QueryUser(db *sql.DB) (string, error) {
// WithTimeout creates a context that cancels after 5 seconds.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
// defer ensures resources are freed even if the function panics.
defer cancel()
var name string
// QueryRowContext passes ctx to the driver for cancellation support.
err := db.QueryRowContext(ctx, "SELECT name FROM users WHERE id = ?", 1).Scan(&name)
if err != nil {
return "", err
}
return name, nil
}
The context.WithTimeout call starts a timer in the background. It returns a context that knows about the timer and a cancel function. You must call cancel when you are done. The defer cancel() ensures the timer is cleaned up when the function returns, whether it succeeds or fails. If you forget to cancel, the timer keeps running until it expires, holding memory and a goroutine. That is a resource leak.
What happens under the hood
When you call QueryRowContext, the database driver receives the context. The driver does not block forever. It polls the context periodically. If the context's Done channel receives a value, the driver knows to stop.
If the query finishes before the timeout, the driver returns the result. The context remains active until cancel is called. The defer cancel() runs, which stops the timer and releases the context resources.
If the timer fires first, the context sends a signal on its Done channel. The driver sees the signal, interrupts the query, and returns an error. Your code gets an error like context deadline exceeded. You can check this error to distinguish a timeout from a database failure.
The cancel function is not just for timeouts. It releases the resources associated with the context. For a timeout context, this stops the timer. For a parent context, this might notify children. Always call cancel to keep your program clean.
Realistic example: HTTP handler and transactions
In a real app, context usually comes from the HTTP request. The server creates a context when the request arrives. You pass that context down to your database call. If the client closes the browser tab, the request context cancels, which cancels the database query.
Here is how an HTTP handler uses context. The handler extracts the context from the request and passes it to the database.
// GetUserHandler handles HTTP requests for user profiles.
func GetUserHandler(w http.ResponseWriter, r *http.Request) {
// r.Context() carries the request lifecycle and client cancellation.
ctx := r.Context()
id := chi.URLParam(r, "id")
var user User
// Pass request context to database query.
err := db.QueryRowContext(ctx, "SELECT * FROM users WHERE id = ?", id).Scan(&user.ID, &user.Name)
if err != nil {
// Check if the error came from context cancellation.
if ctx.Err() != nil {
http.Error(w, "Request cancelled", http.StatusServiceUnavailable)
return
}
http.Error(w, "Not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(user)
}
The r.Context() call returns the context attached to the request. This context cancels when the client disconnects or when the server times out the request. Passing it to QueryRowContext ties the database query to the request lifecycle. If the client leaves, the query stops. This saves database resources and prevents goroutine leaks.
Transactions also need context. Use BeginTx with a context to support cancellation during the transaction.
// UpdateUserWithTx performs a transaction with context support.
func UpdateUserWithTx(ctx context.Context, db *sql.DB, id int, newName string) error {
// BeginTx accepts context to support cancellation during the transaction.
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
// defer ensures the transaction rolls back if the function returns early.
defer tx.Rollback()
_, err = tx.ExecContext(ctx, "UPDATE users SET name = ? WHERE id = ?", newName, id)
if err != nil {
return err
}
// Commit must also respect the context.
return tx.Commit()
}
The BeginTx call takes a context. If the context cancels while the transaction is running, the driver aborts the transaction. The ExecContext call also takes a context. This allows cancellation during individual statements. The defer tx.Rollback() ensures the transaction is cleaned up if the function returns early.
The error check looks verbose. if err != nil is the standard pattern. Go prefers explicit error handling over exceptions. This boilerplate makes the failure path visible. You cannot accidentally swallow an error. The community accepts the verbosity because it makes the unhappy path obvious.
Pitfalls and compiler errors
Common mistakes include forgetting defer cancel(), passing nil context, or storing context in structs.
If you pass nil to a function expecting a context, the compiler rejects this with cannot use nil as context.Context value in argument. Always pass a valid context. Use context.Background() for top-level calls.
If you forget defer cancel(), you get a goroutine leak. The context holds a timer. If you do not cancel, the timer keeps running until it expires, holding memory. The leak is silent. The program works, but memory usage grows over time. Always cancel derived contexts.
Do not store context in structs. Contexts are for request lifecycles, not global state. Storing context in a struct couples the struct to a specific request. This breaks reusability and makes testing harder. Pass context as a parameter instead.
Do not use context.WithValue for passing optional parameters. Context values are for request-scoped metadata like user IDs or trace IDs. Using context for parameters hides the function's dependencies. The compiler cannot check if you are passing the right values. Use explicit parameters for data.
Sometimes you need to ignore a return value. Use _ to discard it intentionally. result, _ := ... tells the reader you considered the second value and chose to drop it. Use this sparingly with errors. Dropping an error silently is usually a bug.
If you forget to import a package, you get undefined: pkg from the compiler. Forget to use one and you get imported and not used. Go enforces clean imports. Remove unused imports to keep the code tidy.
Decision matrix: choosing the right context
Use context.Background() when starting a top-level goroutine or a background worker that has no parent context.
Use context.WithTimeout when you have a hard deadline for an operation, like a database query or an external API call.
Use context.WithCancel when you need manual control over cancellation, such as stopping a long-running task based on a signal from another goroutine.
Use r.Context() in HTTP handlers to tie the operation to the request lifecycle so client disconnection cancels the work.
Use context.WithValue only for passing request-scoped metadata like user IDs or trace IDs, never for passing optional parameters.
Context is plumbing. Run it through every long-lived call site.