You're on call at 2 AM. The monitoring dashboard screams about a spike in 500 errors. You grep the logs and see a wall of database timeout messages. The problem isn't the timeout; it's that you have no idea which request caused it. Was it the checkout flow? The search index? A background job? Without a way to link log lines to a specific request, debugging is guesswork. You need a thread that ties every log entry back to the request that triggered it.
A request ID is a unique string generated at the start of an HTTP request. You attach it to the request context, pass it through your call stack, and print it in every log line. Think of it like a ticket number at a help desk. When you walk in, the clerk gives you a ticket. Every time an agent updates your case, they reference that ticket number. Later, you can pull up the ticket and see the entire history of interactions, even if multiple agents touched it. In Go, the context is the ticket, and the request ID is the number written on it.
The minimal pattern
Here's the simplest way to add request IDs. You generate a UUID in middleware, store it in the context, and extract it in the handler.
// RequestIDKey is a custom type for the context key.
// Custom types avoid collisions if other packages use string keys.
type RequestIDKey struct{}
// withRequestID wraps an http.Handler to inject a unique ID into the context.
func withRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Generate a UUID. This is cheap and globally unique.
reqID := uuid.New().String()
// Store the ID in a new context derived from the request.
ctx := context.WithValue(r.Context(), RequestIDKey{}, reqID)
// Pass the modified request to the next handler in the chain.
next.ServeHTTP(w, r.WithContext(ctx))
})
}
The middleware runs before your handler. It creates a new context using context.WithValue. This function returns a copy of the context that carries the new key-value pair. The original context remains unchanged, which is important because context is immutable. The middleware then calls r.WithContext to create a new request object that holds the updated context. This new request flows down to your handler.
func main() {
mux := http.NewServeMux()
// Attach the middleware to the root handler.
mux.HandleFunc("/", handleRoot)
// Wrap the entire mux so every route gets an ID.
log.Println("Server listening on :8080")
http.ListenAndServe(":8080", withRequestID(mux))
}
// handleRoot demonstrates extracting the ID for logging.
func handleRoot(w http.ResponseWriter, r *http.Request) {
// Retrieve the ID. The type assertion returns false if missing.
if id, ok := r.Context().Value(RequestIDKey{}).(string); ok {
log.Printf("[%s] Request received", id)
}
w.Write([]byte("OK"))
}
The handler calls r.Context() to get the context. It uses Value to look up the key. The comma-ok idiom val, ok := ... is safe practice. If the key is missing or the type doesn't match, ok is false and you avoid a panic. You can log the ID alongside your message. Every log line now carries the identifier.
Context is plumbing. Run it through every long-lived call site.
Why context for request IDs?
The Go documentation warns against using context.WithValue for passing optional parameters. This advice exists because context is the only thing that flows through the call stack automatically. If you start stuffing business data into context, you lose type safety and make the code harder to follow. Request IDs are an exception. They are metadata for observability, not business logic. Every log line needs the ID, and the ID must survive across handler boundaries. Context is the right place because it travels with the request. Just don't use context to pass a userID to a database query. Pass userID as a function argument. Use context only for cancellation, deadlines, and request-scoped metadata like IDs.
There's a convention about the key type. Using a string like "requestID" works, but it risks collisions. Another package might use the same string key for a different purpose. Using a custom type like RequestIDKey{} guarantees uniqueness. The compiler ensures no other package can create the same type. This is a small detail that pays off when debugging complex systems.
Go code looks the same everywhere. Run gofmt on save. Don't argue about indentation.
Realistic usage with structured logging
Production apps rarely use log.Printf. They use structured logging that outputs JSON or key-value pairs. The standard library log/slog package supports this. You can configure slog to extract the request ID from the context automatically. This way, you don't have to pass the ID to every log call.
import (
"context"
"log/slog"
"net/http"
)
// fetchUser simulates a downstream call that needs the context.
// Context is always the first parameter by convention.
func fetchUser(ctx context.Context, id int) (string, error) {
// Log with the context so the request ID appears automatically.
slog.InfoContext(ctx, "fetching user", "id", id)
return "alice", nil
}
// handleUser extracts the ID and calls a service function.
func handleUser(w http.ResponseWriter, r *http.Request) {
// Pass the request context to the service layer.
user, err := fetchUser(r.Context(), 42)
if err != nil {
// Log the error with context. The ID is included.
slog.ErrorContext(r.Context(), "failed to fetch user", "error", err)
http.Error(w, "internal error", 500)
return
}
w.Write([]byte(user))
}
The slog.InfoContext function accepts a context. If you configure the slog handler to look for the request ID key, it adds the ID to every log record. You call slog.InfoContext(ctx, "message") and the output includes the ID without extra arguments. This reduces boilerplate while keeping correlation intact.
Functions that take a context should respect cancellation and deadlines. The context.Context parameter always goes first. This is a community convention. When you see a function signature, the first argument tells you whether the function is long-lived or I/O-bound. If it takes a context, it might block. You can pass a context with a timeout to enforce a deadline.
Error handling is explicit. if err != nil { return err } is the standard pattern. It makes the failure path visible.
Pitfalls and runtime errors
If you use a bare type assertion like ctx.Value(key).(string) and the key is missing, the program panics with interface conversion: interface {} is nil, not string. Always use the comma-ok idiom. The compiler won't catch a missing key; it's a runtime error.
Forgetting to pass the context to a goroutine is a common mistake. When you spawn a goroutine, it starts with a fresh stack. It doesn't inherit the parent's context. If you launch a goroutine to do background work, you must pass the context explicitly.
// BAD: goroutine has no context.
go func() {
processData(data)
}()
// GOOD: pass the context to the goroutine.
go func(ctx context.Context) {
processData(ctx, data)
}(r.Context())
If you forget, the goroutine won't see the request ID in logs, and it won't stop when the client disconnects. This causes goroutine leaks. The goroutine keeps running, holding resources, until the server restarts. The worst goroutine bug is the one that never logs.
If you pass a context with a deadline and the downstream call takes too long, the context cancels. The downstream function should check ctx.Err() and return early. If it doesn't, you get a context deadline exceeded error. This is normal behavior. The context enforces the deadline. Your code should handle the error gracefully.
If you call a function that returns two values but you only need one, use _ to discard the second. id, _ := generateID(). This tells the reader you saw the second value and chose to ignore it. Use it sparingly with errors. Discarding an error without checking it is dangerous.
Decision matrix
Use a request ID in the context when you need to trace a single request across multiple handlers and services. Use a trace ID with a span ID when you have distributed systems and need to correlate requests across service boundaries. Use a simple counter or timestamp when you are debugging a local script and don't need unique correlation. Use structured logging fields when you want to query logs by request ID in a log aggregator. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.