Go Context Package
What is it, and how to use it right.

What is context
Every Go developer encounters context.Context early. It shows up in function signatures everywhere — HTTP handlers, database calls, AWS SDK methods, gRPC stubs. Most beginners copy the pattern without fully understanding it, passing context.Background() everywhere and moving on.
That works until it doesn't. A Lambda that ignores context hangs past its timeout. A database query that ignores context keeps running after the HTTP client disconnected.
Context is a built-in package in the Go standard library that enables the propagation of cancellation signals, deadlines, and values across API boundaries and between processes.
Why Context Exists
Before context was added to the standard library in Go 1.7, there was no standard way to answer these questions across a call chain:
Should this operation still be running, or has the caller given up?
Is there a deadline this operation must finish by?
What request-scoped data (trace ID, user ID, auth token) should flow with this call?
What is really a Context?
The context.Context type is an interface with exactly four methods. Every context implements all four.
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
Here's what each one does.
Deadline() — does this context have an expiry?
deadline, ok := ctx.Deadline()
Returns the time when this context will automatically cancel, and a boolean indicating whether a deadline was set at all. If you created the context with context.Background() or context.WithCancel(), there's no deadline — ok will be false. If you used context.WithTimeout() or context.WithDeadline(), ok is true and deadline tells you exactly when it expires.
You don't call this often in day-to-day code, but it's useful when you need to decide whether there's enough time left to start an expensive operation.
Done() — tell me when to stop
case <-ctx.Done():
Returns a channel that gets closed the moment the context is cancelled or times out. You listen to this channel, typically inside a select statement, to know when to abandon work. Until the context ends, the channel blocks. When it closes, your code unblocks and can clean up.
This is the method you'll use most often in loops and long-running operations.
Err() — why did we stop?
err := ctx.Err()
Returns nil while the context is still alive. Once the context ends, it returns one of two sentinel errors:
context.DeadlineExceeded— the timeout fired automaticallycontext.Canceled— someone calledcancel()manually
This is how you distinguish between "we ran out of time" and "something upstream decided to abort". Both mean stop, but they mean different things for logging and debugging.
Value() — carry data across function calls
id := ctx.Value(myKey)
Retrieves a value stored in the context by key. This is used to pass request-scoped data — like a request ID, a user ID, or an auth token — through a chain of function calls without adding extra parameters to every function signature.
One important rule: use a custom unexported type for your keys (not a plain string), otherwise different packages might accidentally overwrite each other's values.
Context's creation
With contexts, there is a tree structure. The parent context is located at the root of the context tree, and the newly derived context from the parent context is called the child context. Through the parent context, four types of contexts can be derived using the four With methods, and each new context can continue to derive child contexts, making the whole structure look like a tree.
Parent Context
There are two ways to create the parent context: an empty context still with no functionality.
context.Background()
ctx := context.Background()
Use this at the top of your program — in main(), in init(), or as the starting point when creating a server. It's the foundation everything else derives from.
context.TODO()
Semantically identical to context.Background() at runtime, but signals intent: "I know a context should go here, I haven't figured out which one yet."
ctx := context.TODO() // placeholder — replace with a real context later
Use it when you're refactoring code to add context support and haven't wired up the full chain yet. It's a marker for future work, not a production pattern.
Child Contexts
To make the context useful, we use one of the following child contexts:
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithValue(parent Context, key, val interface{}) Context
context.WithCancel()
Returns a copy of the parent context with a new Done channel, plus a cancel function. Calling cancel closes the Done channel and marks the context as canceled.
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // always call cancel
Always call cancel. If you don't, the context and everything derived from it leaks until the parent is canceled or the program exits. defer cancel() immediately after creation is the idiomatic pattern.
context.WithTimeout() and context.WithDeadline()
These set an automatic cancellation time. WithTimeout takes a duration; WithDeadline takes an absolute time.Time.
// Cancel after 5 seconds
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Cancel at a specific moment
deadline := time.Now().Add(5 * time.Second)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()
WithTimeout(parent, d) is shorthand for WithDeadline(parent, time.Now().Add(d)). They behave identically. When the deadline passes, the context's Done channel is closed automatically and Err() returns context.DeadlineExceeded.
Still call defer cancel() even with a timeout — if your operation finishes before the deadline, calling cancel releases the timer resources immediately rather than waiting for it to fire.
context.WithValue
Creates a child context from the parent context for value passing. Used for passing context information, such as the unique request ID and trace ID, for link tracing and configuration passing.
ctx := context.WithValue(context.Background(), authToken, "XYZ_123")
fmt.Println(ctx.Value(authToken))
How Cancellation Propagates
Remember, contexts form a tree. When a parent context is canceled, all contexts derived from it are canceled too — automatically, immediately, without any extra code.
parent, cancelParent := context.WithCancel(context.Background())
child1, cancelChild1 := context.WithCancel(parent)
child2, cancelTimeout := context.WithTimeout(parent, 10*time.Second)
// Canceling the parent cancels both children
cancelParent()
// child1.Err() == context.Canceled
// child2.Err() == context.Canceled
This is the mechanism that makes context useful across a call tree. An HTTP handler creates a context with a timeout. It passes that context to a database call, which passes it to a connection pool, which passes it to a network read. When the HTTP client disconnects, the handler's context is canceled, and that cancellation flows down through every layer automatically.
Real-World Use Cases
HTTP Servers: Respecting Client Disconnects
Go's net/http package attaches a context to every incoming request. The context is canceled when the client disconnects or the server's write deadline fires. Your handler receives it via r.Context().
The following is a fully runnable example. It simulates a slow db.Search using a select with time.After, shows what happens when the client disconnects mid-query, and adds a middleware that attaches a request ID to the context, so every layer of the call chain can log it without it being passed as an explicit argument. You can find the repo here:
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"time"
"github.com/google/uuid"
)
// contextKey is an unexported type for context keys in this package.
// Using a custom type prevents collisions with keys from other packages
// that might also store something under a plain string like "request_id".
type contextKey string
const keyRequestID contextKey = "request_id"
// withRequestID attaches a request ID to the context.
func withRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, keyRequestID, id)
}
// requestIDFromContext retrieves the request ID from the context.
// Returns an empty string if no ID was set — callers should handle that gracefully.
func requestIDFromContext(ctx context.Context) string {
id, _ := ctx.Value(keyRequestID).(string)
return id
}
var logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
},
}))
// requestIDMiddleware generates a unique ID for every incoming request,
// attaches it to the context, and sends it back in the response header.
// Every function below the handler can read it from ctx for logging —
// without it being passed as a function argument anywhere.
func requestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := uuid.NewString()
ctx := withRequestID(r.Context(), requestID)
w.Header().Set("X-Request-ID", requestID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Search simulates a slow database query that respects context cancellation.
// It reads the request ID directly from ctx — no extra parameter needed.
func Search(ctx context.Context, query string, delay time.Duration) ([]string, error) {
requestID := requestIDFromContext(ctx)
logger.Info("db.Search started",
"request_id", requestID,
"query", query,
"simulated_delay", delay,
)
select {
case <-time.After(delay):
logger.Info("db.Search completed", "request_id", requestID, "query", query)
return []string{"result-1", "result-2", "result-3"}, nil
case <-ctx.Done():
logger.Warn("db.Search aborted",
"request_id", requestID,
"query", query,
"reason", ctx.Err(),
)
return nil, ctx.Err()
}
}
func searchHandler(w http.ResponseWriter, r *http.Request) {
requestID := requestIDFromContext(r.Context())
query := r.URL.Query().Get("q")
if query == "" {
query = "gophers"
}
delay := 5 * time.Second
if d := r.URL.Query().Get("delay"); d != "" {
if parsed, err := time.ParseDuration(d); err == nil {
delay = parsed
}
}
logger.Info("handler started",
"request_id", requestID,
"query", query,
"client", r.RemoteAddr,
)
// r.Context() carries both the cancellation signal AND the request ID value.
// Search receives one ctx and gets everything it needs from it.
results, err := Search(r.Context(), query, delay)
if err != nil {
if errors.Is(err, context.Canceled) {
logger.Warn("client disconnected before query finished, no response sent",
"request_id", requestID,
)
return
}
logger.Error("unexpected search error", "request_id", requestID, "error", err)
http.Error(w, "search failed", http.StatusInternalServerError)
return
}
logger.Info("handler finished — sending response",
"request_id", requestID,
"result_count", len(results),
)
fmt.Fprintf(w, "results: %v\n", results)
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/search", searchHandler)
// Wrap the mux — every request gets a request ID attached to its context
handler := requestIDMiddleware(mux)
logger.Info("server listening on :8080")
logger.Info("try: curl -v 'http://localhost:8080/search?q=gophers&delay=5s'")
logger.Info("cancel with Ctrl+C before 5s to trigger client disconnect")
if err := http.ListenAndServe(":8080", handler); err != nil {
logger.Error("server error", "error", err)
os.Exit(1)
}
}
Before running, initialize the module and fetch the uuid package, which is in charge of creating random uuids for the incoming requests:
go mod init example/context-demo
go get github.com/google/uuid
To test the cancellation path:
# Terminal 1 — start the server
go run main.go
# Terminal 2 — send a request with a 5s delay, then hit Ctrl+C before it finishes
curl 'http://localhost:8080/search?q=gophers&delay=5s'
# Press Ctrl+C after ~2 seconds
You should see in the server logs:
level=INFO msg="handler started" request_id=4a1f9c2e-... query=gophers client=127.0.0.1:PORT
level=INFO msg="db.Search started" request_id=4a1f9c2e-... query=gophers simulated_delay=5s
level=WARN msg="db.Search aborted" request_id=4a1f9c2e-... query=gophers reason="context canceled"
level=WARN msg="client disconnected before query finished, no response sent" request_id=4a1f9c2e-...
Notice the same request_id appears in every log line, from the middleware, through the handler, all the way into Search, without it ever being passed as a function argument. It traveled through the context.
You can also verify the ID was sent back in the response header:
curl -v 'http://localhost:8080/search?q=gophers&delay=5s'| grep X-Request-ID
# X-Request-ID: 4a1f9c2e-...
To test the happy path
Let the query finish before disconnecting:
curl 'http://localhost:8080/search?q=gophers&delay=2s'
# Wait for it to complete
level=INFO msg="handler started" request_id=9b3d7f1a-... query=gophers client=127.0.0.1:PORT
level=INFO msg="db.Search started" request_id=9b3d7f1a-... query=gophers simulated_delay=2s
level=INFO msg="db.Search completed" request_id=9b3d7f1a-... query=gophers
level=INFO msg="handler finished — sending response" request_id=9b3d7f1a-... result_count=3
The key is the select inside Search. It blocks on two channels simultaneously: time.After(delay) represents the slow query, and ctx.Done() represents the client. Whichever fires first wins. If the client disconnects, ctx.Done() closes and Search returns context.Canceled immediately, without waiting for the timer.
This example shows both things context is good for working together in one flow: the cancellation signal (ctx.Done()) stops the query when the client leaves, and the value (request_id) flows through every layer for correlated logging, all through the same single ctx argument.
Database Calls: Enforcing Query Timeouts
Most database drivers in Go accept a context. Pass one with a timeout to prevent slow queries from holding connections indefinitely.
func getUserByID(ctx context.Context, db *sql.DB, userID string) (*User, error) {
// Give the query 3 seconds — no matter what the caller's deadline is
queryCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
var user User
err := db.QueryRowContext(queryCtx,
"SELECT id, name, email FROM users WHERE id = $1", userID,
).Scan(&user.ID, &user.Name, &user.Email)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return nil, fmt.Errorf("user query timed out after 3s: %w", err)
}
return nil, fmt.Errorf("querying user %s: %w", userID, err)
}
return &user, nil
}
Notice that queryCtx derives from ctx — the caller's context. If the caller's context is already canceled (client disconnected, Lambda timed out), queryCtx is canceled immediately regardless of the 3-second timeout. The child inherits the parent's cancellation, but can set a tighter deadline on top of it.
Checking for Cancellation in Long Loops
If your function does significant work in a loop, check ctx.Err() periodically so it can exit early when canceled.
func processFiles(ctx context.Context, files []string) error {
for _, file := range files {
// Check at the top of each iteration
if err := ctx.Err(); err != nil {
return fmt.Errorf("processing canceled after %s: %w", file, err)
}
if err := processFile(ctx, file); err != nil {
return fmt.Errorf("processing %s: %w", file, err)
}
}
return nil
}
Without this check, a canceled context doesn't stop the loop — it only stops operations that accept and check the context themselves. If processFile takes 500ms and you have 1000 files, a cancellation mid-loop still runs all remaining iterations.
Common Mistakes and Gotchas
Passing context.Background() everywhere. It works, but it opts every operation out of cancellation and timeout propagation. If your HTTP handler times out, your database queries keep running. Pass the incoming context through your call chain — that's the whole point.
Not calling cancel(). Every WithCancel, WithTimeout, and WithDeadline must have its cancel called. The Go runtime cannot garbage-collect a context until cancel is called or the parent is canceled. defer cancel() right after creation is non-negotiable.
Storing contexts in structs. The Go documentation is explicit: do not store contexts in structs. A context is for a single operation, not for the lifetime of an object. Pass it as the first argument to every function that needs it.
// Wrong
type Service struct {
ctx context.Context // don't do this
}
// Right
func (s *Service) DoWork(ctx context.Context) error { ... }
Using context.WithValue for function dependencies. If you're pulling a database client out of a context, something has gone wrong. Use dependency injection or package-level variables for clients and config. Context values are for request-scoped metadata.
Ignoring ctx.Err() in loops. Passing a context to a function that calls ctx.Err() isn't enough if your own loop never checks it. Long-running loops need explicit cancellation checks.
Wrapping errors without checking for cancellation. When a context-aware call fails, check whether the cause is cancellation before deciding how to handle it:
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
// The operation was stopped — not necessarily a bug
return err
}
// Real error — log, alert, or return
return fmt.Errorf("unexpected error: %w", err)
}
Summary
The context package gives every Go operation a standard way to answer three questions: should I keep running, when must I finish by, and what metadata flows with this request?
The key things to take away:
context.Background()is the root — use it at program entry points.WithCancel,WithTimeout, andWithDeadlinecreate derived contexts — alwaysdefer cancel().Cancellation propagates from parent to child automatically — wire it through your call chain and it works for free.
Pass context as the first argument to every function that does I/O — HTTP, database, AWS SDK, gRPC.
WithValueis for cross-cutting metadata like trace IDs, not for dependencies.Check
ctx.Err()explicitly in long-running loops.


