Go Context Package: Cancellation and Deadlines
Go's `context` package solves a fundamental problem in concurrent programming: how do you tell a goroutine to stop what it's doing? When you spawn goroutines to handle HTTP requests, database...
Key Insights
- Context enables cooperative cancellation across goroutines by providing a standardized way to signal when operations should stop, preventing resource leaks and zombie goroutines
- Always pass context as the first parameter to functions and use
WithTimeoutorWithDeadlinefor operations with time constraints rather than implementing custom timeout logic - Never store context in structs—pass it explicitly through the call chain to maintain clear cancellation boundaries and avoid subtle bugs with long-lived objects
Understanding the Context Package
Go’s context package solves a fundamental problem in concurrent programming: how do you tell a goroutine to stop what it’s doing? When you spawn goroutines to handle HTTP requests, database queries, or external API calls, you need a mechanism to cancel them when the client disconnects, a timeout expires, or the operation is no longer needed.
The context package provides this mechanism through a simple interface that carries deadlines, cancellation signals, and request-scoped values across API boundaries. It’s not optional—it’s essential for writing production-grade Go services.
The Context Interface
The context.Context interface defines four methods:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
Deadline()returns when the context will be cancelled automaticallyDone()returns a channel that closes when the context is cancelledErr()explains why the context was cancelledValue()retrieves request-scoped values (use sparingly)
Every context forms a tree structure. When you create a child context from a parent, cancelling the parent automatically cancels all children. This cascading cancellation is the key to proper resource cleanup.
Start with context.Background() for top-level contexts (like main() or test functions) and context.TODO() when you’re unsure which context to use but plan to add one later:
func main() {
ctx := context.Background()
processRequest(ctx)
}
func processRequest(ctx context.Context) {
// Use ctx throughout the call chain
}
Manual Cancellation
Use context.WithCancel() when you need explicit control over when to stop operations. It returns a new context and a cancel function:
func handleRequest(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithCancel(r.Context())
defer cancel() // Always call cancel to release resources
results := make(chan string, 3)
// Spawn multiple workers
for i := 0; i < 3; i++ {
go worker(ctx, i, results)
}
// Wait for first result or cancellation
select {
case result := <-results:
w.Write([]byte(result))
cancel() // Cancel remaining workers
case <-ctx.Done():
http.Error(w, "Request cancelled", http.StatusRequestTimeout)
}
}
func worker(ctx context.Context, id int, results chan<- string) {
// Simulate work with cancellation checks
for i := 0; i < 10; i++ {
select {
case <-ctx.Done():
// Clean up and exit
return
default:
time.Sleep(100 * time.Millisecond)
}
}
results <- fmt.Sprintf("Worker %d completed", id)
}
The pattern here is critical: each worker checks ctx.Done() periodically. When the context is cancelled, the channel closes, and workers exit gracefully. Always defer the cancel function to prevent context leaks.
Timeouts and Deadlines
Most operations should have time limits. context.WithTimeout() creates a context that cancels automatically after a duration:
func queryDatabase(ctx context.Context, query string) ([]Result, error) {
// Set 5-second timeout for this operation
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
resultCh := make(chan []Result, 1)
errCh := make(chan error, 1)
go func() {
// Simulate database query
time.Sleep(3 * time.Second)
resultCh <- []Result{{ID: 1, Name: "Example"}}
}()
select {
case results := <-resultCh:
return results, nil
case err := <-errCh:
return nil, err
case <-ctx.Done():
return nil, ctx.Err() // Returns context.DeadlineExceeded
}
}
context.WithDeadline() works similarly but takes an absolute time:
func callExternalAPI(ctx context.Context) (*Response, error) {
// Must complete by specific time
deadline := time.Now().Add(10 * time.Second)
ctx, cancel := context.WithDeadline(ctx, deadline)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", "https://api.example.com", nil)
if err != nil {
return nil, err
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
// Check if timeout caused the error
if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("API call timed out: %w", err)
}
return nil, err
}
defer resp.Body.Close()
// Process response...
return &Response{}, nil
}
Use WithTimeout when you know how long an operation should take. Use WithDeadline when you have an absolute time constraint (like a request that must complete before a specific timestamp).
Real-World Context Propagation
Here’s a complete example showing context flowing through an HTTP handler to a service layer and database:
func main() {
http.HandleFunc("/users", getUsersHandler)
http.ListenAndServe(":8080", nil)
}
func getUsersHandler(w http.ResponseWriter, r *http.Request) {
// HTTP server provides base context
ctx := r.Context()
// Add timeout for entire request
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
users, err := fetchUsers(ctx)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
http.Error(w, "Request timeout", http.StatusGatewayTimeout)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(users)
}
func fetchUsers(ctx context.Context) ([]User, error) {
// Service layer receives context
db := getDBConnection()
// Pass context to database layer
rows, err := db.QueryContext(ctx, "SELECT id, name FROM users")
if err != nil {
return nil, fmt.Errorf("query failed: %w", err)
}
defer rows.Close()
var users []User
for rows.Next() {
// Check cancellation during iteration
if ctx.Err() != nil {
return nil, ctx.Err()
}
var u User
if err := rows.Scan(&u.ID, &u.Name); err != nil {
return nil, err
}
users = append(users, u)
}
return users, nil
}
type User struct {
ID int
Name string
}
Notice how context flows from the HTTP request through the handler to the service and database layers. Each layer respects the cancellation signal. When the client disconnects or the timeout expires, all layers stop working.
Best Practices and Common Mistakes
Do: Pass context as the first parameter
// Correct
func ProcessData(ctx context.Context, data []byte) error
// Wrong
func ProcessData(data []byte, ctx context.Context) error
Don’t: Store context in structs
// Wrong - contexts in structs cause confusion about lifetime
type Worker struct {
ctx context.Context
}
// Correct - pass context to methods
type Worker struct {
// other fields
}
func (w *Worker) Process(ctx context.Context) error {
// use ctx here
}
Do: Check for cancellation in loops
// Correct
for _, item := range items {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
process(item)
}
// Wrong - no cancellation check, loop runs to completion
for _, item := range items {
process(item)
}
Do: Always call cancel functions
// Correct - prevents context leak
ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second)
defer cancel()
// Wrong - context resources not released until timeout
ctx, _ := context.WithTimeout(parentCtx, 5*time.Second)
Don’t: Pass nil context
// Wrong
doWork(nil)
// Correct
doWork(context.Background())
When to Use What
Use context.WithCancel() when you need manual control—like cancelling multiple operations when one completes first, or implementing graceful shutdown.
Use context.WithTimeout() for operations with known duration limits—API calls, database queries, or any operation that shouldn’t run indefinitely.
Use context.WithDeadline() when you have absolute time constraints—batch jobs that must complete by a specific time, or coordinating multiple operations that share a deadline.
The context package isn’t complicated, but it’s powerful. Master these patterns and your Go services will handle cancellation cleanly, prevent resource leaks, and respect timeouts consistently. Every function that does I/O or spawns goroutines should accept a context as its first parameter. No exceptions.