How to Implement Request ID Middleware in Go

Web
Add unique request tracking to Go HTTP servers using context and middleware.

The lost log line

You deploy a Go service to production. Traffic spikes. A user reports a payment failed. You grep the logs for the timestamp. You find three hundred entries. Which one belongs to the user? You need a unique thread to follow. A request ID solves this. It ties every log line, database query, and downstream call to a single identifier.

Without a request ID, debugging is archaeology. You piece together fragments based on IP addresses, user agents, and timestamps. IP addresses lie behind NATs. User agents repeat. Timestamps drift. A request ID is the only reliable way to trace a single request through your entire stack.

Middleware and context

Middleware sits between the server and your handler. It intercepts the request, does something, and passes it along. In Go, middleware is just a function that takes an http.Handler and returns an http.Handler. It wraps the existing handler in a new one.

Context is the value that travels with the request. It carries deadlines, cancellation signals, and request-scoped data. Putting the ID in the context makes it available to every function down the call stack without threading arguments manually. The context flows from the middleware to the handler, to the database driver, to the logging function.

Think of the context as a backpack. The middleware zips the ID into the backpack before the request starts walking. Every function along the path can open the backpack and check the ID. The backpack is immutable. You never modify the contents of an existing context. You create a new context with the new value and pass that forward.

Context is the backpack. Middleware is the gatekeeper.

The minimal implementation

Here is the simplest goroutine: spawn one, send a message, close the channel. Here is the simplest request ID middleware: generate a UUID, put it in the context, set the header, pass the request.

package main

import (
    "context"
    "net/http"
    "github.com/google/uuid"
)

// RequestIDMiddleware generates a unique ID and attaches it to the request context.
func RequestIDMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Generate a version 4 UUID for uniqueness without coordination
        id := uuid.New().String()
        
        // Store the ID in the context using a custom key type
        ctx := context.WithValue(r.Context(), contextKey("requestID"), id)
        
        // Echo the ID back to the client in the response header
        w.Header().Set("X-Request-ID", id)
        
        // Pass the modified request with the new context to the next handler
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

The function signature func(http.Handler) http.Handler is the standard middleware pattern. It accepts the next handler in the chain and returns a new handler that wraps it. Inside, http.HandlerFunc adapts a function to the http.Handler interface.

The uuid.New() call generates a random version 4 UUID. It requires no network calls and no database lookups. It is fast enough for every request. The context.WithValue call creates a new context that carries the ID. The w.Header().Set call ensures the client receives the ID in the response. This allows frontend code or load balancers to correlate responses with requests.

The r.WithContext(ctx) call creates a shallow copy of the request with the new context. You must use this copy when calling next.ServeHTTP. If you pass the original request, the downstream handler sees the old context without the ID.

Don't fight the context. Use custom keys or lose the data.

How the request flows

The compiler sees the middleware as a function returning a handler. The runtime sees a chain of execution.

A request arrives at the server. The server calls the middleware's ServeHTTP method. The middleware generates the UUID. It creates a new context. It sets the header. It calls next.ServeHTTP with the modified request.

The next handler runs. It calls r.Context().Value(key) to retrieve the ID. It logs the ID. It processes the request. It writes the response. The response bubbles back up through the middleware chain. The client receives the response with the X-Request-ID header.

Every function that needs the ID calls r.Context().Value(key). The context lookup is fast. It walks a small linked list of context nodes. The overhead is negligible compared to network I/O or database queries.

The worst log line is the one without an ID.

A production-ready pattern

The minimal example uses a string literal as the context key. This works, but it risks collisions. If another package uses the string "requestID" as a key, you overwrite each other's values. The Go community prefers a custom unexported type for context keys. This guarantees uniqueness.

Here is the middleware with a custom key type.

// contextKey is a custom type to prevent collisions with other packages
type contextKey string

const requestIDKey contextKey = "requestID"

// RequestIDMiddleware generates a unique ID and attaches it to the request context.
func RequestIDMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Generate a random UUID v4
        id := uuid.New().String()
        
        // Create a new context carrying the ID
        ctx := context.WithValue(r.Context(), requestIDKey, id)
        
        // Set the header so the client can correlate responses
        w.Header().Set("X-Request-ID", id)
        
        // Clone the request with the new context and pass it downstream
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

The contextKey type is unexported. Other packages cannot create values of this type. The constant requestIDKey is the only key. Collision is impossible.

Here is a handler that uses the ID.

// HandleHello retrieves the request ID and logs it
func HandleHello(w http.ResponseWriter, r *http.Request) {
    // Value returns an interface{}, so type assert to string
    id, ok := r.Context().Value(requestIDKey).(string)
    if !ok {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    
    // Log the ID to correlate this line with the request
    log.Printf("[%s] processing hello", id)
    w.Write([]byte("Hello"))
}

func main() {
    mux := http.NewServeMux()
    // Apply middleware by wrapping the handler
    mux.Handle("/", RequestIDMiddleware(http.HandlerFunc(HandleHello)))
    
    http.ListenAndServe(":8080", mux)
}

The handler retrieves the ID with r.Context().Value(requestIDKey). The Value method returns an interface{}. You must type assert to string. The ok idiom checks if the assertion succeeded. If the middleware did not run, the assertion fails. The handler returns an error.

The main function wires everything together. mux.Handle registers the wrapped handler. http.ListenAndServe starts the server. The middleware runs for every request matching the route.

Trust gofmt. Argue logic, not formatting.

Pitfalls and compiler traps

Forgetting to update the request context is the most common mistake. The compiler does not stop you. r is still a valid *http.Request. The handler runs. The handler calls r.Context().Value(key). The value is missing. The logs are uncorrelated.

If you forget to import the uuid package, the compiler rejects the build with undefined: uuid. If you try to use a string key without a custom type, the compiler won't stop you, but you risk runtime collisions. If you forget r.WithContext, the handler receives the original context. The compiler sees no error because r is still a valid *http.Request.

Another trap is modifying the context in the handler. Contexts are immutable. You cannot add values to a context after it is created. You must create a new context with the new value. If you need to pass data from the handler to a downstream function, create a new context and pass it explicitly.

Panic handling is another consideration. If the UUID generation panics, the server crashes. UUID generation rarely panics, but it is possible. Wrap the middleware in a defer and recover block if you need to protect the server.

func SafeRequestIDMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                log.Printf("middleware panic: %v", err)
                http.Error(w, "internal server error", http.StatusInternalServerError)
            }
        }()
        
        id := uuid.New().String()
        ctx := context.WithValue(r.Context(), requestIDKey, id)
        w.Header().Set("X-Request-ID", id)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

The defer block runs after the handler returns. If a panic occurs, recover catches it. The middleware logs the panic and returns a 500 error. The server stays alive.

Don't swallow panics. Log them and fail fast.

When to use request IDs

Use request ID middleware when you need to correlate logs within a single service. Use distributed tracing when you need to follow a request across multiple network boundaries. Use session cookies when you need to maintain state for a user across multiple requests. Use request IDs when you want a simple, low-overhead way to track requests. Use OpenTelemetry when you need rich metadata and visualization. Use request IDs when you are building a monolith or a small microservice. Use distributed tracing when you are building a large microservice architecture.

Request IDs are cheap. Distributed tracing is not.

Where to go next