Hi there! I'm Shrijith Venkatrama, founder of Hexmos. Right now, I’m building LiveAPI, a first of its kind tool for helping you automatically index API endpoints across all your repositories. LiveAPI helps you discover, understand and use APIs in large tech infrastructures with ease.
Go’s concurrency model is one of its superpowers, but it can get tricky when you need to coordinate, cancel, or timeout operations. That’s where contexts come in—a powerful tool in Go’s standard library to manage goroutines, deadlines, and cancellations. In this post, we’ll dive deep into Go’s context
package, explore its mechanics, and walk through practical examples you can run yourself. Let’s get started.
What Are Contexts in Go?
The context
package, introduced in Go 1.7, provides a way to carry cancellation signals, deadlines, and request-scoped values across goroutines. Think of it as a control mechanism for managing the lifecycle of concurrent operations. Contexts are especially useful in scenarios like HTTP servers, database queries, or any task that might need to be canceled or timed out.
Key points:
- Contexts are defined in the
context
package. - They help manage goroutine lifecycles by signaling cancellation or timeouts.
- They’re widely used in APIs, servers, and distributed systems.
Here’s a simple example to show a context in action:
package main
import (
"context"
"fmt"
"time"
)
func main() {
// Create a context with a 2-second timeout
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() // Always call cancel to release resources
go doWork(ctx)
// Wait for the context to timeout or be canceled
<-ctx.Done()
fmt.Println("Main: Context canceled, reason:", ctx.Err())
}
func doWork(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("doWork: Stopped because:", ctx.Err())
return
default:
fmt.Println("doWork: Working...")
time.Sleep(500 * time.Millisecond)
}
}
}
// Output (after ~2 seconds):
// doWork: Working...
// doWork: Working...
// doWork: Working...
// doWork: Working...
// doWork: Stopped because: context deadline exceeded
// Main: Context canceled, reason: context deadline exceeded
This code creates a context with a timeout, runs a goroutine, and stops it when the timeout expires. The select
statement checks for cancellation, and ctx.Err()
tells us why the context ended.
Official Go context package documentation
Why Contexts Matter for Concurrency
Concurrency in Go is built around goroutines and channels, but without proper coordination, you can end up with leaked goroutines or unresponsive systems. Contexts solve this by providing a standardized way to signal when a task should stop.
Key points:
- Contexts prevent goroutines from running indefinitely.
- They’re essential for handling external events like HTTP request cancellations.
- They make your code more robust and resource-efficient.
Imagine a web server handling a request that queries a database. If the client disconnects, you want to stop the query to save resources. Contexts make this possible.
Problem Without Contexts | Solution With Contexts |
---|---|
Goroutines keep running after a request is canceled | Context signals cancellation to stop goroutines |
No way to enforce timeouts | Context enforces deadlines or timeouts |
Resource leaks from abandoned tasks | Context ensures cleanup with cancel()
|
Creating and Using Contexts
The context
package provides several ways to create contexts. The most common starting point is context.Background()
, which creates an empty, non-cancelable context. From there, you can derive new contexts with specific behaviors.
Key points:
- Use
context.Background()
as the root context for top-level operations. - Derive contexts with
context.WithCancel
,context.WithTimeout
, orcontext.WithDeadline
. - Always call the
cancel
function to release resources.
Here’s an example showing different context types:
package main
import (
"context"
"fmt"
"time"
)
func main() {
// 1. Context with cancellation
ctx, cancel := context.WithCancel(context.Background())
go workWithCancel(ctx)
time.Sleep(1 * time.Second)
cancel() // Manually cancel the context
time.Sleep(100 * time.Millisecond) // Wait for goroutine to exit
// 2. Context with timeout
ctx, cancel = context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
go workWithTimeout(ctx)
<-ctx.Done()
fmt.Println("Main: Timeout context done, reason:", ctx.Err())
// 3. Context with deadline
deadline := time.Now().Add(1 * time.Second)
ctx, cancel = context.WithDeadline(context.Background(), deadline)
defer cancel()
go workWithDeadline(ctx)
<-ctx.Done()
fmt.Println("Main: Deadline context done, reason:", ctx.Err())
}
func workWithCancel(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("workWithCancel: Stopped because:", ctx.Err())
return
default:
fmt.Println("workWithCancel: Working...")
time.Sleep(200 * time.Millisecond)
}
}
}
func workWithTimeout(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("workWithTimeout: Stopped because:", ctx.Err())
return
default:
fmt.Println("workWithTimeout: Working...")
time.Sleep(200 * time.Millisecond)
}
}
}
func workWithDeadline(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("workWithDeadline: Stopped because:", ctx.Err())
return
default:
fmt.Println("workWithDeadline: Working...")
time.Sleep(200 * time.Millisecond)
}
}
}
// Output:
// workWithCancel: Working...
// workWithCancel: Working...
// workWithCancel: Working...
// workWithCancel: Working...
// workWithCancel: Stopped because: context canceled
// workWithTimeout: Working...
// workWithTimeout: Working...
// workWithTimeout: Working...
// workWithTimeout: Working...
// workWithTimeout: Stopped because: context deadline exceeded
// Main: Timeout context done, reason: context deadline exceeded
// workWithDeadline: Working...
// workWithDeadline: Working...
// workWithDeadline: Stopped because: context deadline exceeded
// Main: Deadline context done, reason: context deadline exceeded
This example shows three context types: manual cancellation, timeout, and deadline. Each goroutine stops when its context signals completion.
Passing Contexts Through Functions
Contexts are designed to be passed through your program’s call stack. You should never store contexts in structs or global variables—instead, pass them explicitly as the first parameter to functions.
Key points:
- Always pass contexts as the first argument, typically named
ctx
. - Avoid storing contexts; they’re meant to be short-lived.
- Use contexts to propagate cancellation across layers.
Here’s an example of a function chain using contexts:
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
// Pass context through functions
result, err := processRequest(ctx, "user123")
if err != nil {
fmt.Println("Error:", err)
} else {
fmt.Println("Result:", result)
}
}
func processRequest(ctx context.Context, userID string) (string, error) {
// Simulate some work
result, err := fetchUserData(ctx, userID)
if err != nil {
return "", err
}
return fmt.Sprintf("Processed data for %s: %s", userID, result), nil
}
func fetchUserData(ctx context.Context, userID string) (string, error) {
// Simulate a database call
select {
case <-time.After(2 * time.Second):
return fmt.Sprintf("Data for %s", userID), nil
case <-ctx.Done():
return "", ctx.Err()
}
}
// Output:
// Error: context deadline exceeded
Here, the context’s timeout stops fetchUserData
before it completes, propagating the error up the call stack.
Handling Context Values
Contexts can carry request-scoped values, like user IDs or request metadata. This is done using context.WithValue
, but use it sparingly—values should be rare and truly request-scoped.
Key points:
- Use
context.WithValue
to attach key-value pairs to a context. - Avoid overuse; contexts aren’t a general-purpose data store.
- Keys should be unexported types to prevent collisions.
Here’s an example:
package main
import (
"context"
"fmt"
)
type contextKey string
const userIDKey contextKey = "userID"
func main() {
ctx := context.WithValue(context.Background(), userIDKey, "user123")
// Pass context to a function
processWithUserID(ctx)
}
func processWithUserID(ctx context.Context) {
userID, ok := ctx.Value(userIDKey).(string)
if !ok {
fmt.Println("No user ID found in context")
return
}
fmt.Println("Processing for user:", userID)
}
// Output:
// Processing for user: user123
This code attaches a user ID to the context and retrieves it in a function. The custom contextKey
type prevents key collisions.
Common Context Pitfalls
Using contexts incorrectly can lead to subtle bugs. Here are some common mistakes and how to avoid them.
Key points:
-
Always call
cancel()
to prevent resource leaks. - Don’t ignore
ctx.Err()
afterctx.Done()
is signaled. - Avoid passing
nil
contexts; usecontext.Background()
instead.
Mistake | Fix |
---|---|
Forgetting to call cancel()
|
Use defer cancel() after creating a context |
Passing nil context |
Always start with context.Background() or context.TODO()
|
Overusing context.WithValue
|
Use function parameters for most data |
Here’s an example of a common mistake and its fix:
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
// Forgot defer cancel() -- resource leak!
go badWorker(ctx)
time.Sleep(2 * time.Second)
fmt.Println("Main: Done")
}
func badWorker(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("badWorker: Stopped")
return
default:
fmt.Println("badWorker: Working...")
time.Sleep(200 * time.Millisecond)
}
}
}
// Output (leaks resources):
// badWorker: Working...
// badWorker: Working...
// badWorker: Stopped
// Main: Done
// Fixed version:
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel() // Fixed: release resources
go goodWorker(ctx)
time.Sleep(2 * time.Second)
fmt.Println("Main: Done")
}
func goodWorker(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("goodWorker: Stopped")
return
default:
fmt.Println("goodWorker: Working...")
time.Sleep(200 * time.Millisecond)
}
}
}
// Output (no leaks):
// goodWorker: Working...
// goodWorker: Working...
// goodWorker: Stopped
// Main: Done
Contexts in Real-World Applications
Contexts shine in real-world scenarios like HTTP servers or database clients. For example, in an HTTP server, the http.Request
object includes a context that’s canceled when the client disconnects.
Key points:
- Use
req.Context()
in HTTP handlers to respect client cancellations. - Contexts integrate with libraries like
database/sql
or gRPC. - They ensure resources are freed when operations are no longer needed.
Here’s an HTTP server example:
package main
import (
"context"
"fmt"
"net/http"
"time"
)
func main() {
http.HandleFunc("/work", workHandler)
fmt.Println("Server starting on :8080...")
http.ListenAndServe(":8080", nil)
}
func workHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
select {
case <-time.After(2 * time.Second):
fmt.Fprintln(w, "Work completed!")
case <-ctx.Done():
fmt.Println("workHandler: Client disconnected, reason:", ctx.Err())
}
}
// Output (if client disconnects early):
// workHandler: Client disconnected, reason: context canceled
Test this by curling http://localhost:8080/work
and canceling with Ctrl+C. The server detects the cancellation via the context.
Best Practices for Using Contexts
To wrap up, here are actionable tips for using contexts effectively:
-
Start with
context.Background()
for top-level contexts. -
Always defer
cancel()
to avoid resource leaks. - Pass contexts explicitly as the first parameter to functions.
- Use
context.WithTimeout
orcontext.WithDeadline
for time-sensitive operations. - Reserve
context.WithValue
for request-scoped data like user IDs. - Check
ctx.Err()
afterctx.Done()
to understand why a context ended. - Test your code with cancellations and timeouts to ensure robustness.
By following these practices, you’ll write cleaner, more reliable concurrent Go code. Contexts are a small but mighty tool—use them wisely, and they’ll save you from concurrency headaches. Now go build something awesome with Go!
Top comments (2)
pretty cool seeing all the real code, tbh i always wonder if habits or just sticking with best practices matter more for long-term success in stuff like this
In my experience under time and money constraints - habit trumps best practices. Deep and intense training is key