Go’s concurrency model is powerful, but managing the lifecycle of potentially thousands of goroutines, especially in network servers or complex applications, requires a robust mechanism. There is the context
package – a beautiful and powerful tool of modern Go development for managing cancellation, deadlines, and passing request-scoped data across API boundaries and between goroutines.
This knowledge is pretty known to most Go devs but still documenting it for my own reference and if it helps you, that’s great. Taken a bit of help from LLM to write this post for formatting and some content.
Will dive deep into context.Context
, explaining why it’s essential and how to use it effectively. We’ll cover:
- The Basics: Creating and passing contexts.
- Cancellation: Gracefully stopping operations.
- Deadlines & Timeouts: Automatically cancelling based on time.
- Request-Scoped Values: Passing data safely.
- Best Practices: Writing idiomatic context-aware Go code.
Let’s get started!
What is context.Context
?
At its core, context.Context
is an interface. A Context
value can carry:
- Cancellation Signals: A way to tell functions downstream that the operation they are part of should be abandoned (e.g., a user cancelled a request).
- Deadlines: A specific time after which an operation should be cancelled.
- Timeouts: A duration after which an operation should be cancelled.
- Request-Scoped Values: Key-value pairs associated with a specific request or operation (like trace IDs or user authentication info).
It’s primarily used in scenarios involving concurrency, network requests, or any potentially long-running operation where cancellation or timeouts are beneficial.
Creating Your First Context
Every context tree needs a root. The context
package provides two functions for this:
context.Background()
: Returns a non-nil, empty Context. It’s never cancelled, has no values, and no deadline. It’s typically used bymain
, initialization, and tests as the top-level Context.context.TODO()
: Similar toBackground
, but convention dictates using it when you’re unsure which Context to use or if the surrounding function hasn’t yet been updated to accept a Context. It acts as a placeholder.
Convention: Pass context.Context
as the first argument to functions, typically named ctx
.
package main
import (
"context"
"fmt"
"time"
)
// processTask simulates a task that takes some time.
// It accepts a context as its first argument.
func processTask(ctx context.Context, taskID int) {
fmt.Printf("Task %d started.\n", taskID)
// Simulate work
time.Sleep(50 * time.Millisecond)
fmt.Printf("Task %d finished.\n", taskID)
}
func main() {
// Create a background context as the root.
rootCtx := context.Background()
fmt.Println("Starting main process...")
processTask(rootCtx, 1)
fmt.Println("Main process finished.")
}
This simple example shows the basic structure, but the rootCtx
isn’t doing much yet. The real power comes when we derive new contexts from it.
Cancellation: Stopping Goroutines Gracefully
Imagine a web server handling a request. If the client disconnects, you want to stop any ongoing work for that request (like database queries) to save resources. context.WithCancel
provides this capability.
context.WithCancel(parent Context) (ctx Context, cancel CancelFunc)
It returns a derived context (ctx
) and a CancelFunc
. Calling cancel()
signals cancellation to ctx
and any contexts derived from it.
Crucial: You must call the cancel
function eventually to release resources associated with the context, even if the operation completes normally. Using defer cancel()
is the idiomatic way to ensure this.
Detecting Cancellation: Functions receiving a context should listen on its Done()
channel. ctx.Done()
returns a channel that’s closed when the context is cancelled or times out. A common pattern is using a select
statement inside a loop.
package main
import (
"context"
"fmt"
"time"
)
// worker performs a task but listens for context cancellation.
func worker(ctx context.Context, id int) {
fmt.Printf("Worker %d: Started\n", id)
defer fmt.Printf("Worker %d: Stopped\n", id) // Ensure stop message is printed
for {
select {
case <-time.After(500 * time.Millisecond): // Simulate doing some work
fmt.Printf("Worker %d: Working...\n", id)
case <-ctx.Done(): // Context was cancelled
fmt.Printf("Worker %d: Cancellation signal received: %v\n", id, ctx.Err())
return // Exit the worker function
}
}
}
func main() {
// Create a background context
rootCtx := context.Background()
// Create a cancellable context derived from the root context
// IMPORTANT: Always call cancel() to free resources
cancelCtx, cancel := context.WithCancel(rootCtx)
defer cancel() // Ensures cancel() is called when main exits
fmt.Println("Starting worker...")
go worker(cancelCtx, 1)
// Let the worker run for a bit
time.Sleep(2 * time.Second)
// Now, explicitly cancel the context
fmt.Println("Main: Cancelling context...")
cancel() // Signal cancellation
// Give the worker a moment to react and print its stopped message
time.Sleep(100 * time.Millisecond)
fmt.Println("Main: Finished.")
}
Output (order might vary slightly):
Starting worker...
Worker 1: Started
Worker 1: Working...
Worker 1: Working...
Worker 1: Working...
Worker 1: Working...
Main: Cancelling context...
Worker 1: Cancellation signal received: context canceled
Worker 1: Stopped
Main: Finished.
Notice how the worker stops processing after cancel()
is called and prints the reason using ctx.Err()
, which returns context.Canceled
here.
Deadlines and Timeouts: Automatic Cancellation
Sometimes, you don’t want to cancel manually, but rather enforce a time limit.
context.WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
: Cancels the context when the system clock reaches the deadlined
.context.WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
: A convenience wrapper aroundWithDeadline
. It calculates the deadline astime.Now().Add(timeout)
and callsWithDeadline
.
Again, you must call the returned cancel
function to release resources, even if the timeout/deadline is met. defer cancel()
is essential.
If the deadline or timeout is exceeded, ctx.Done()
is closed, and ctx.Err()
returns context.DeadlineExceeded
.
package main
import (
"context"
"fmt"
"time"
)
// longRunningTask simulates an operation that might exceed a timeout.
func longRunningTask(ctx context.Context) error {
fmt.Println("Task: Starting long operation...")
defer fmt.Println("Task: Finishing operation...")
// Simulate work that takes 3 seconds
deadline, ok := ctx.Deadline()
if ok {
fmt.Printf("Task: Deadline set at %v\n", deadline.Format(time.RFC3339))
} else {
fmt.Println("Task: No deadline set.")
}
select {
case <-time.After(3 * time.Second):
fmt.Println("Task: Operation completed successfully.")
return nil // Completed normally
case <-ctx.Done(): // Context timed out or was cancelled
fmt.Printf("Task: Operation cancelled/timed out: %v\n", ctx.Err())
return ctx.Err() // Return the context error
}
}
func main() {
rootCtx := context.Background()
// Create a context that will time out after 2 seconds.
// Timeout is shorter than the task's 3-second duration.
timeoutCtx, cancel := context.WithTimeout(rootCtx, 2*time.Second)
defer cancel() // IMPORTANT: Ensure cancel is called
fmt.Println("Main: Starting task with 2-second timeout...")
err := longRunningTask(timeoutCtx)
if err != nil {
fmt.Printf("Main: Task failed: %v\n", err)
} else {
fmt.Println("Main: Task succeeded.")
}
fmt.Println("Main: Finished.")
}
Output:
Main: Starting task with 2-second timeout...
Task: Starting long operation...
Task: Deadline set at 2025-03-29T07:XX:XX+05:30 <-- Actual time will vary
Task: Operation cancelled/timed out: context deadline exceeded
Task: Finishing operation...
Main: Task failed: context deadline exceeded
Main: Finished.
Here, the longRunningTask
takes 3 seconds, but the context times out after 2 seconds. The ctx.Done()
channel closes, the select case triggers, and ctx.Err()
returns context.DeadlineExceeded
.
Carrying Request-Scoped Values with context.WithValue
Contexts can also carry request-scoped data across API boundaries and between goroutines. This is useful for things like trace IDs, user credentials, or locale information without cluttering function signatures.
context.WithValue(parent Context, key, val interface{}) Context
It returns a copy of the parent context with the key-value pair added.
Retrieving Values: Use the Value(key interface{}) interface{}
method. It searches up the context tree for the key and returns the first value found, or nil
.
Critical Best Practice: Do not use built-in types (like string
or int
) as context keys. Different packages might accidentally use the same key, leading to collisions. Instead, define an unexported custom type for your keys.
package main
import (
"context"
"fmt"
)
// Define a custom type for our context key. Being unexported (lowercase)
// prevents collisions with keys defined in other packages.
type contextKey string
// Define specific keys of our custom type.
const (
userIDKey contextKey = "userID"
traceIDKey contextKey = "traceID"
)
func processRequest(ctx context.Context) {
// Retrieve values using the specific key types.
// Need type assertion because ctx.Value returns interface{}.
userID, ok := ctx.Value(userIDKey).(int)
if !ok {
fmt.Println("Process: User ID not found in context")
} else {
fmt.Printf("Process: Processing request for User ID: %d\n", userID)
}
traceID, ok := ctx.Value(traceIDKey).(string)
if !ok {
fmt.Println("Process: Trace ID not found in context")
} else {
fmt.Printf("Process: Trace ID: %s\n", traceID)
}
// Simulate further processing
fmt.Println("Process: Request processing complete.")
}
func main() {
rootCtx := context.Background()
// Add values to the context. Note how WithValue creates a new context.
ctxWithUser := context.WithValue(rootCtx, userIDKey, 12345)
ctxWithTrace := context.WithValue(ctxWithUser, traceIDKey, "abc-xyz-123")
fmt.Println("Main: Calling processRequest...")
processRequest(ctxWithTrace)
// Demonstrate retrieving from an intermediate context
fmt.Println("\nMain: Calling processRequest with only userID context...")
processRequest(ctxWithUser)
// Demonstrate retrieving from root context (no values)
fmt.Println("\nMain: Calling processRequest with root context...")
processRequest(rootCtx)
}
Output:
Main: Calling processRequest...
Process: Processing request for User ID: 12345
Process: Trace ID: abc-xyz-123
Process: Request processing complete.
Main: Calling processRequest with only userID context...
Process: Processing request for User ID: 12345
Process: Trace ID not found in context
Process: Request processing complete.
Main: Calling processRequest with root context...
Process: User ID not found in context
Process: Trace ID not found in context
Process: Request processing complete.
Caveats with WithValue
:
- Use it only for request-scoped data that crosses API boundaries, not for passing optional parameters to functions. Explicit parameters are clearer.
- Values stored are
interface{}
, requiring type assertions and potentially leading to runtime panics if the type is wrong. - It can make dependencies less explicit.
Context Best Practices Recap
- Pass
Context
as the first argument: Name itctx
. - Start with
context.Background()
: Typically inmain
or request handlers. Usecontext.TODO()
only as a temporary placeholder. - Propagate context: Pass the received
ctx
down the call chain. - Use
WithValue
sparingly: Prefer explicit parameters for required data. Use unexported custom types for keys. - Listen for cancellation: Use
select
withctx.Done()
in long-running functions or goroutines. - ALWAYS call
cancel()
: Usedefer cancel()
immediately after getting aCancelFunc
fromWithCancel
,WithDeadline
, orWithTimeout
. - Check
ctx.Err()
: Afterctx.Done()
is closed, checkctx.Err()
to know why it was cancelled (context.Canceled
orcontext.DeadlineExceeded
).
Conclusion
The context
package is indispensable for writing robust, efficient, and scalable Go applications, especially those dealing with concurrency and network I/O. By mastering cancellation, deadlines, timeouts, and the careful use of request-scoped values, you can effectively manage goroutine lifecycles, prevent resource leaks, and build more resilient systems. Remember the conventions and best practices, especially calling cancel()
, and context
will become a powerful tool in your Go arsenal.