Introduction#
context.Context is Go’s mechanism for propagating cancellation signals, deadlines, and request-scoped values across goroutine boundaries. Correct context usage is essential for building servers that respond gracefully to timeouts and shutdowns.
Context Basics#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| // The four context constructors
ctx := context.Background() // root context, never cancelled
ctx := context.TODO() // placeholder, same as Background()
ctx, cancel := context.WithCancel(parent) // cancelled by calling cancel()
defer cancel() // ALWAYS defer cancel to avoid context leaks
ctx, cancel := context.WithTimeout(parent, 5*time.Second) // deadline in duration
defer cancel()
ctx, cancel := context.WithDeadline(parent, time.Now().Add(5*time.Second))
defer cancel()
ctx := context.WithValue(parent, key, value) // attach values
|
Propagating Context Through Call Chains#
Every function that does I/O or calls other services must accept and pass a context.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| // CORRECT: context as first parameter
func GetUser(ctx context.Context, userID int) (*User, error) {
return db.QueryRowContext(ctx, "SELECT * FROM users WHERE id = $1", userID)
}
// HTTP handler: extract context from request
func handleGetUser(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() // deadline from server's read timeout
userID := extractUserID(r)
user, err := GetUser(ctx, userID)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "upstream timeout", http.StatusGatewayTimeout)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(user)
}
|
Setting Timeouts on Outbound Calls#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| func fetchUserProfile(ctx context.Context, userID int) (*Profile, error) {
// Add a timeout for this specific downstream call
// even if the parent context has a longer deadline
callCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(callCtx, "GET",
fmt.Sprintf("https://profile-service/users/%d", userID), nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("profile service: %w", err)
}
defer resp.Body.Close()
var profile Profile
return &profile, json.NewDecoder(resp.Body).Decode(&profile)
}
|
Checking Cancellation in Long Operations#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| func processItems(ctx context.Context, items []Item) error {
for i, item := range items {
// Check for cancellation every iteration
select {
case <-ctx.Done():
return fmt.Errorf("cancelled after %d items: %w", i, ctx.Err())
default:
}
if err := processItem(ctx, item); err != nil {
return err
}
}
return nil
}
// Alternative: check inline
func heavyComputation(ctx context.Context, data []float64) (float64, error) {
result := 0.0
for i, v := range data {
if i%1000 == 0 { // check every 1000 iterations
if err := ctx.Err(); err != nil {
return 0, err
}
}
result += compute(v)
}
return result, nil
}
|
Context Values: Request-Scoped Data#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| // Define typed keys to avoid collisions
type contextKey string
const (
requestIDKey contextKey = "request_id"
userIDKey contextKey = "user_id"
)
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey, id)
}
func RequestIDFromContext(ctx context.Context) (string, bool) {
id, ok := ctx.Value(requestIDKey).(string)
return id, ok
}
// HTTP middleware: attach request ID to context
func requestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqID := r.Header.Get("X-Request-ID")
if reqID == "" {
reqID = uuid.New().String()
}
ctx := WithRequestID(r.Context(), reqID)
w.Header().Set("X-Request-ID", reqID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
|
Context values should hold request-scoped data (trace IDs, auth tokens) — not optional function parameters. If a function needs a value to work correctly, pass it as a regular parameter.
Context Leak Detection#
1
2
3
4
5
6
7
8
9
10
11
12
13
| // WRONG: cancel not called — context leaks until parent is cancelled
func badCode(ctx context.Context) {
childCtx, _ := context.WithTimeout(ctx, 5*time.Second)
// childCtx is never cancelled explicitly
doWork(childCtx)
}
// CORRECT: always defer cancel
func goodCode(ctx context.Context) {
childCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
doWork(childCtx)
}
|
Use go vet and golangci-lint to detect missing cancel calls:
1
2
| # golangci-lint includes contextcheck linter
golangci-lint run --enable contextcheck ./...
|
Server Shutdown with Context#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| func main() {
server := &http.Server{Addr: ":8080"}
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)
go func() {
if err := server.ListenAndServe(); err != http.ErrServerClosed {
log.Fatal(err)
}
}()
<-quit
log.Println("Shutting down...")
// Give in-flight requests 30 seconds to complete
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Fatalf("Shutdown error: %v", err)
}
log.Println("Server stopped")
}
|
Conclusion#
Pass context as the first parameter to every function that does I/O. Add per-call timeouts for downstream services rather than relying solely on the parent deadline. Always defer cancel() immediately after creating a cancellable context. Use typed keys for context values. Check ctx.Done() in long-running loops to enable prompt cancellation.