Context in Go: timeouts, cancellations and robust requests
context.Context in Go explained: cancellations, timeouts, propagation in HTTP, databases and workers. A key piece for real services.

Context in Go confused me for weeks. I saw it in every function signature, passed it mechanically as the first argument, and didn’t understand why it existed. It seemed like a formality of the language, something you had to put there just because. Then I built a service where each request called three external APIs and ran a query against PostgreSQL, and suddenly everything clicked. If one of those calls took 30 seconds, the other three kept waiting. If the client closed the connection, the server kept processing a response that nobody was going to receive. Without context, your service has no way to say “stop, this doesn’t matter anymore”.
It’s not a secondary piece. It’s one of the most important abstractions in Go for anything that goes beyond a script.
What is context.Context: the interface
context.Context is an interface with four methods:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}Deadline()returns the moment the context will expire, if it has one defined.Done()returns a channel that closes when the context is cancelled or expires. It’s the key piece: you canselecton this channel to react to cancellation.Err()returnsnilwhile the context is active,context.Canceledif it was manually cancelled, orcontext.DeadlineExceededif the timeout expired.Value(key)returns values associated with the context. Use it with caution (I explain why later).
What makes context.Context special is that it’s immutable and hierarchical. You never modify an existing context: you create a new one derived from the previous one. If you cancel a parent context, all children are automatically cancelled. This property is what allows cancellation signals to propagate through the entire call chain of your application.
context.Background() and context.TODO()
These are the two root contexts offered by the standard library:
ctx := context.Background()
ctx := context.TODO()context.Background() is the default empty context. It has no deadline, cannot be cancelled, has no values. You use it as a starting point when no other context is available: in main(), in your application’s initialization, or when starting a background worker.
context.TODO() is functionally identical to Background(), but with a different intent: it marks a place where you know there should be a real context but you haven’t implemented it yet. It’s a reminder in the code, not a permanent solution.
func main() {
ctx := context.Background()
server := NewServer(ctx)
server.Start()
}In practice, if you see context.TODO() in a production codebase, it’s a signal that someone left something half-done. Treat it as technical debt.
context.WithCancel: manual cancellation
context.WithCancel creates a derived context that you can cancel explicitly by calling a function:
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
// long work
select {
case <-ctx.Done():
fmt.Println("cancelled:", ctx.Err())
return
case result := <-doWork():
fmt.Println("result:", result)
}
}()
// At some point you decide to cancel
cancel()The cancel function is idempotent: you can call it multiple times without issue. And you must always call it, even if the work finishes first. If you don’t call it, the context and its internal resources stay in memory until the parent is cancelled or the program ends. The defer cancel() immediately after creating the context is a mandatory pattern.
A real case where you need manual cancellation: you have several goroutines running the same query against different database replicas. The first one to respond wins, and you cancel the rest.
func queryFastest(ctx context.Context, replicas []string, query string) (Result, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
results := make(chan Result, len(replicas))
for _, replica := range replicas {
go func(addr string) {
result, err := queryReplica(ctx, addr, query)
if err == nil {
results <- result
}
}(replica)
}
select {
case r := <-results:
return r, nil
case <-ctx.Done():
return Result{}, ctx.Err()
}
}When the first goroutine sends its result and the function returns, defer cancel() cancels the context. The goroutines that were still waiting for a response from their replicas see the ctx.Done() channel close and can clean up resources.
context.WithTimeout: automatic deadline
context.WithTimeout is probably the variant you’ll use most. It creates a context that cancels automatically after a duration:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
result, err := callExternalAPI(ctx)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
log.Println("the API took more than 5 seconds")
}
return err
}The difference from WithCancel is that you don’t need to decide when to cancel. You define the maximum time and Go handles the rest. But it’s still mandatory to call cancel with defer. If the operation finishes in 100ms but the timeout was 5 seconds, without defer cancel() the internal timer keeps running for the remaining 4.9 seconds, consuming resources.
What many people don’t understand at first: the timeout applies to the entire chain of operations that use that context. If you pass a context with a 5-second timeout to a function that makes three sequential HTTP calls, all three share those 5 seconds. It’s not 5 seconds for each one.
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
// All three calls share the 5 seconds
user, err := fetchUser(ctx, userID) // takes 2s
orders, err := fetchOrders(ctx, userID) // takes 2s
recommendations, err := fetchRecs(ctx, user) // ~1s left, will likely failThis is intentional. The timeout represents the total time budget for the operation, not for each individual step. If you need individual timeouts, create derived contexts:
ctx, cancel := context.WithTimeout(ctx, 10*time.Second) // global
defer cancel()
userCtx, userCancel := context.WithTimeout(ctx, 3*time.Second) // max 3s for user
defer userCancel()
user, err := fetchUser(userCtx, userID)
ordersCtx, ordersCancel := context.WithTimeout(ctx, 3*time.Second) // max 3s for orders
defer ordersCancel()
orders, err := fetchOrders(ordersCtx, userID)context.WithDeadline: absolute time limit
context.WithDeadline works just like WithTimeout, but instead of a duration, you specify an exact moment:
deadline := time.Now().Add(30 * time.Second)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()In practice, WithTimeout is syntactic sugar over WithDeadline. Internally, context.WithTimeout(parent, d) is equivalent to context.WithDeadline(parent, time.Now().Add(d)).
When would you use WithDeadline directly? When the deadline comes from outside. For example, if you receive an HTTP header that says “this request must complete before 14:30:05 UTC”, you use WithDeadline with that absolute value.
There’s an important detail: a child cannot extend its parent’s deadline. If the parent expires in 5 seconds and you create a child with WithTimeout(parent, 10*time.Second), the child will still expire in 5 seconds. The most restrictive context always wins.
parent, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// This does NOT give 10 seconds. The child inherits the parent's deadline (5s).
child, childCancel := context.WithTimeout(parent, 10*time.Second)
defer childCancel()You can check the effective deadline:
if deadline, ok := ctx.Deadline(); ok {
remaining := time.Until(deadline)
fmt.Printf("%v remaining\n", remaining)
}Propagating context through your application
The fundamental rule: context is always the first parameter of a function and is called ctx. This is not an aesthetic convention, it’s the standard across the entire Go ecosystem.
// Correct
func GetUser(ctx context.Context, id int64) (*User, error)
// Wrong
func GetUser(id int64, ctx context.Context) (*User, error)
// Wrong: never store context in a struct
type Service struct {
ctx context.Context // DON'T do this
}Storing a context in a struct is a mistake that seems convenient but breaks the propagation model. A context is tied to a specific operation (an HTTP request, a job execution). If you store it in a struct, that struct becomes tied to an operation that has already finished, or worse, you share a context between different operations.
Correct propagation is linear: each layer of your application receives the context and passes it to the next.
// Handler → Service → Repository
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user, err := h.service.FindUser(ctx, userID)
// ...
}
func (s *Service) FindUser(ctx context.Context, id int64) (*User, error) {
return s.repo.GetByID(ctx, id)
}
func (r *Repository) GetByID(ctx context.Context, id int64) (*User, error) {
row := r.db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = $1", id)
// ...
}If the client closes the HTTP connection, r.Context() is cancelled, the cancellation propagates to the service, from the service to the repository, and the database query is aborted. All without you writing explicit cancellation logic in each layer. The context does it for you.
Context in HTTP handlers: per-request context
Every HTTP request in Go carries its own context. You get it with r.Context():
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// This context is automatically cancelled when:
// 1. The client closes the connection
// 2. The server cancels the request (e.g., due to server timeout)
result, err := processRequest(ctx)
if err != nil {
if errors.Is(err, context.Canceled) {
// Client left, no point writing a response
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(result)
}If you use a framework or middleware that adds timeouts, these apply to the request context. It’s common to have a middleware that wraps each request with a maximum timeout:
func TimeoutMiddleware(timeout time.Duration) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer cancel()
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
})
}
}With r.WithContext(ctx) you create a copy of the request with the new context. All downstream functions that use r.Context() will see the applied timeout.
A detail that causes subtle bugs: if your handler launches a goroutine for background work, don’t use r.Context() for that goroutine. When the HTTP request ends, the context is cancelled, and your background goroutine gets cancelled too.
func handler(w http.ResponseWriter, r *http.Request) {
// BAD: this goroutine gets cancelled when the HTTP request ends
go sendAnalytics(r.Context(), event)
// GOOD: independent context for background work
go sendAnalytics(context.Background(), event)
w.WriteHeader(http.StatusOK)
}Context with database queries: pgx and database/sql
The standard library database/sql supports context in all its operations. If you use pgx (the most used PostgreSQL driver in Go), the support is even better.
With database/sql:
func (r *Repository) GetUser(ctx context.Context, id int64) (*User, error) {
query := "SELECT id, name, email FROM users WHERE id = $1"
var user User
err := r.db.QueryRowContext(ctx, query, id).Scan(&user.ID, &user.Name, &user.Email)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return nil, fmt.Errorf("timeout querying user %d: %w", id, err)
}
return nil, fmt.Errorf("error querying user %d: %w", id, err)
}
return &user, nil
}With pgx directly:
func (r *Repository) GetUser(ctx context.Context, id int64) (*User, error) {
query := "SELECT id, name, email FROM users WHERE id = $1"
var user User
err := r.pool.QueryRow(ctx, query, id).Scan(&user.ID, &user.Name, &user.Email)
if err != nil {
return nil, fmt.Errorf("error querying user %d: %w", id, err)
}
return &user, nil
}pgx uses the context as the first parameter instead of having separate *Context methods. It’s a cleaner design.
The key point here: when the context is cancelled, the database receives a signal to abort the query. It’s not just that your Go code stops waiting for the response; PostgreSQL actually cancels the query on the server. This is critical for heavy queries. Without context, a query that takes 60 seconds keeps consuming resources on the database server even after the client has left.
Transactions with context:
func (r *Repository) TransferFunds(ctx context.Context, from, to int64, amount float64) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx, "UPDATE accounts SET balance = balance - $1 WHERE id = $2", amount, from)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, "UPDATE accounts SET balance = balance + $1 WHERE id = $2", amount, to)
if err != nil {
return err
}
return tx.Commit()
}If the context is cancelled between the two UPDATE statements, the transaction is aborted and rolled back. You don’t end up with an inconsistent state. This is something that in other languages you need to implement manually; in Go, propagating the context gives it to you for free.
Context in goroutines and workers
Worker pools in Go need context for two things: knowing when to stop and respecting system timeouts.
A basic worker that respects cancellation:
func worker(ctx context.Context, id int, jobs <-chan Job, results chan<- Result) {
for {
select {
case <-ctx.Done():
fmt.Printf("worker %d: stopping (%v)\n", id, ctx.Err())
return
case job, ok := <-jobs:
if !ok {
return // channel closed, no more work
}
result := process(ctx, job)
select {
case results <- result:
case <-ctx.Done():
return
}
}
}
}A worker pool with graceful cancellation:
func RunWorkerPool(ctx context.Context, numWorkers int, jobs <-chan Job) []Result {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
results := make(chan Result, numWorkers)
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
worker(ctx, id, jobs, results)
}(i)
}
// Close results when all workers finish
go func() {
wg.Wait()
close(results)
}()
var collected []Result
for r := range results {
collected = append(collected, r)
}
return collected
}The pattern is always the same: the select with ctx.Done() at any point where the worker may block. If you only put it when receiving the job but not when sending the result, the worker can get stuck on results <- result if nobody is reading the channel.
For periodic tasks (cron-like), the context controls when to stop the loop:
func periodicTask(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := doWork(ctx); err != nil {
log.Printf("error in periodic task: %v", err)
}
}
}
}Without context, you have no clean way to tell this loop to stop. You end up using atomic boolean flags or manual channels, which is exactly what context abstracts away.
Context values: when to use them (rarely) and when not to
context.WithValue allows you to attach key-value pairs to a context:
type contextKey string
const requestIDKey contextKey = "requestID"
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey, id)
}
func GetRequestID(ctx context.Context) string {
id, ok := ctx.Value(requestIDKey).(string)
if !ok {
return ""
}
return id
}The private contextKey type is mandatory. If you use string directly as the key, any package that uses the same string can collide. With your own unexported type, only your package can access the value.
When it makes sense to use context.WithValue:
- Request IDs and trace IDs for logging and observability.
- Authentication information (the authenticated user of the request).
- Metadata that crosses package boundaries and doesn’t belong in the function signature.
When not to use it:
- Business parameters. If a function needs a
userID, put it as an explicit parameter. Don’t hide it in the context. - Dependencies. Never put a logger, a database connection or a service in the context.
- Anything the function needs to work correctly. If without that value the function fails, it should be a required parameter with a concrete type.
The practical rule: if you remove the value from the context and the function should still compile and work (perhaps with less information in the logs), then it’s fine in the context. If without the value the function can’t do its job, it’s a function parameter.
A typical middleware that injects values into the context:
func RequestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
id = uuid.New().String()
}
ctx := WithRequestID(r.Context(), id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}And downstream:
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
reqID := GetRequestID(r.Context())
log.Printf("[%s] processing GetUser", reqID)
// ...
}Common mistakes: ignoring context and not propagating it
I’ve seen these patterns more times than I’d like:
1. Receiving context and not using it
// BAD: accepts ctx but uses http.DefaultClient (which ignores the context)
func fetchData(ctx context.Context, url string) ([]byte, error) {
resp, err := http.Get(url) // http.Get doesn't use your ctx
return io.ReadAll(resp.Body)
}
// GOOD: uses the context in the request
func fetchData(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}http.Get creates a request without context. If you use http.Get in a function that receives ctx, you’re lying in the signature: you promise to respect cancellation but you don’t. Always use http.NewRequestWithContext.
2. Not propagating context between layers
// BAD: the service creates its own context, ignoring the handler's
func (s *Service) Process(ctx context.Context, data Data) error {
dbCtx := context.Background() // Why? This ignores the handler's timeout
return s.repo.Save(dbCtx, data)
}
// GOOD: propagates the handler's context
func (s *Service) Process(ctx context.Context, data Data) error {
return s.repo.Save(ctx, data)
}If you create a new context.Background() in the middle of the chain, you break all propagation. The handler might have a 10-second timeout, but your repository doesn’t know because you passed it a context with no deadline.
3. Not checking cancellation in loops
// BAD: if ctx is cancelled, this loop keeps processing thousands of items
func processAll(ctx context.Context, items []Item) error {
for _, item := range items {
if err := process(item); err != nil {
return err
}
}
return nil
}
// GOOD: check cancellation periodically
func processAll(ctx context.Context, items []Item) error {
for _, item := range items {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if err := process(item); err != nil {
return err
}
}
return nil
}The select with default is non-blocking: it checks if the context is cancelled and, if not, continues immediately. The cost is minimal but saves you from processing thousands of unnecessary items when the request no longer matters.
4. Forgetting defer cancel()
// BAD: internal timer leak
func doSomething() {
ctx, _ := context.WithTimeout(context.Background(), 5*time.Second)
callAPI(ctx)
}
// GOOD
func doSomething() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
callAPI(ctx)
}The go vet linter will warn you about this. If you don’t have linting configured, configure it. It’s too easy to forget the cancel.
Practical example: HTTP handler → service → repository with context
Let’s bring it all together in a realistic example. An endpoint that looks up a user, queries their orders, and returns a combined response. The handler applies a global timeout, and each operation respects cancellation.
Starting with the repository:
package repository
import (
"context"
"database/sql"
"fmt"
)
type UserRepository struct {
db *sql.DB
}
func NewUserRepository(db *sql.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) GetByID(ctx context.Context, id int64) (*User, error) {
query := "SELECT id, name, email FROM users WHERE id = $1"
var user User
err := r.db.QueryRowContext(ctx, query, id).Scan(&user.ID, &user.Name, &user.Email)
if err != nil {
return nil, fmt.Errorf("user %d: %w", id, err)
}
return &user, nil
}
type OrderRepository struct {
db *sql.DB
}
func NewOrderRepository(db *sql.DB) *OrderRepository {
return &OrderRepository{db: db}
}
func (r *OrderRepository) GetByUserID(ctx context.Context, userID int64) ([]Order, error) {
query := "SELECT id, product, amount FROM orders WHERE user_id = $1 ORDER BY id DESC LIMIT 10"
rows, err := r.db.QueryContext(ctx, query, userID)
if err != nil {
return nil, fmt.Errorf("orders for user %d: %w", userID, err)
}
defer rows.Close()
var orders []Order
for rows.Next() {
var o Order
if err := rows.Scan(&o.ID, &o.Product, &o.Amount); err != nil {
return nil, err
}
orders = append(orders, o)
}
return orders, rows.Err()
}The service orchestrates the two queries. If either fails or the context is cancelled, it returns an error:
package service
import (
"context"
"fmt"
)
type UserService struct {
users *repository.UserRepository
orders *repository.OrderRepository
}
func NewUserService(users *repository.UserRepository, orders *repository.OrderRepository) *UserService {
return &UserService{users: users, orders: orders}
}
func (s *UserService) GetUserWithOrders(ctx context.Context, userID int64) (*UserProfile, error) {
user, err := s.users.GetByID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("fetching user: %w", err)
}
orders, err := s.orders.GetByUserID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("fetching orders: %w", err)
}
return &UserProfile{
User: *user,
Orders: orders,
}, nil
}And the HTTP handler, which applies a 5-second timeout to the entire operation:
package handler
import (
"context"
"encoding/json"
"errors"
"log"
"net/http"
"strconv"
"time"
)
type UserHandler struct {
service *service.UserService
}
func NewUserHandler(service *service.UserService) *UserHandler {
return &UserHandler{service: service}
}
func (h *UserHandler) GetUserProfile(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
reqID := GetRequestID(ctx) // from middleware
userID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
profile, err := h.service.GetUserWithOrders(ctx, userID)
if err != nil {
switch {
case errors.Is(err, context.DeadlineExceeded):
log.Printf("[%s] timeout getting user profile %d", reqID, userID)
http.Error(w, "timeout", http.StatusGatewayTimeout)
case errors.Is(err, context.Canceled):
// Client disconnected, no need to respond
log.Printf("[%s] client disconnected for user %d", reqID, userID)
default:
log.Printf("[%s] error getting user profile %d: %v", reqID, userID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(profile)
}Notice how the flow works:
- The handler creates a context with a 5-second timeout, derived from the HTTP request context.
- That same context is passed to the service, which passes it to the repositories.
- The repositories pass it to
database/sql, which uses it to limit queries. - If the database takes more than 5 seconds in total (across both queries), the context expires and a 504 is returned.
- If the client closes the connection,
r.Context()is cancelled, which cancels the child context with timeout, which cancels the queries.
Each layer does one thing: receive the context, use it, propagate it. No boolean “shouldStop” flags, no manual “done” channels. The context manages everything.
When you don’t need context
Not everything needs a context. Pure functions that do computations, in-memory data transformations, validations… if the operation cannot block and doesn’t interact with I/O, it doesn’t need ctx context.Context. Adding it systematically only pollutes your API.
The question is simple: can this function block waiting for something external (network, disk, channel)? If yes, it needs context. If not, it doesn’t.
// Doesn't need context: it's an in-memory calculation
func CalculateDiscount(price float64, percentage int) float64 {
return price * (1 - float64(percentage)/100)
}
// Does need context: it does I/O
func FetchPrice(ctx context.Context, productID string) (float64, error) {
// ...
}The ceremony that saves you in production
When I started with Go, context felt excessively ceremonial. Always first parameter, always called ctx, always defer cancel() when creating contexts with WithCancel, WithTimeout or WithDeadline. Never store it in a struct, propagate it to all I/O operations, check ctx.Done() in loops and goroutines. And context.WithValue only for cross-cutting metadata like request ID or trace ID, never for business parameters.
That opinion changed when I had a service with 20 endpoints, calling 5 external APIs with a database behind them. Each request has an automatic “stop everything if this no longer matters” mechanism, and that’s exactly what you need when a client disconnects in the middle of a chain of operations. Without context, you would have had to invent boolean flags, manual done channels, or simply let operations finish wasting resources. It’s one of those Go design decisions you don’t appreciate until it saves you an incident at three in the morning.
To see it in action with real APIs, check the article on how to build a REST API with Go. If you want to go deeper into how context interacts with concurrency in Go or with PostgreSQL with Go, those articles cover the specific details of each case.


