How to Implement Logging Middleware in Go

Web
Wrap your HTTP handler in a function that logs request details before and after execution to implement logging middleware in Go.

The silent failure

You ship a new endpoint. Traffic doubles overnight. The server starts timing out, but your logs show nothing useful. You could sprinkle fmt.Println across every handler, but that clutters your business logic and makes it impossible to filter later. What you actually need is a single place that intercepts every request, records the basics, and lets the rest of your code stay focused on returning data.

How middleware actually works

Middleware is just a function that wraps another function. In Go, the net/http package defines http.Handler as an interface with one method: ServeHTTP(http.ResponseWriter, *http.Request). Any type that implements that method can handle HTTP requests. Middleware takes an existing handler, wraps it in a new function, runs some code before and after the inner handler executes, and returns the wrapper. It works like a security desk. You check credentials, stamp the visitor badge, let them into the building, wait for them to finish, and then log their exit time. The building interior never knows the desk exists.

Go makes this pattern frictionless because of how it treats functions and interfaces. You do not need a framework. You do not need reflection. You just need a function that matches the signature. The standard library gives you the exact building blocks to chain handlers together without boilerplate.

Middleware is just a function that wraps another function. Keep the chain thin.

The minimal wrapper

Here is the simplest logging middleware you can write. It records the HTTP method, the path, and how long the request took.

package main

import (
	"log"
	"net/http"
	"time"
)

// LoggingMiddleware wraps an http.Handler to log request details.
func LoggingMiddleware(next http.Handler) http.Handler {
	// http.HandlerFunc is an adapter that lets a plain function satisfy http.Handler.
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		// Pass the request to the next handler in the chain.
		next.ServeHTTP(w, r)
		// Calculate duration after the inner handler returns.
		log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
	})
}

Apply it by passing your existing handler through the wrapper before starting the server. The wrapper becomes the entry point for every incoming request.

func main() {
	// Wrap the default mux with logging.
	handler := LoggingMiddleware(http.DefaultServeMux)
	log.Fatal(http.ListenAndServe(":8080", handler))
}

What happens under the hood

When you compile this, the Go type system checks the return value of LoggingMiddleware. The function signature says it must return an http.Handler. A bare function literal does not automatically satisfy an interface. The compiler rejects the code with cannot use func literal as type http.Handler in return argument if you drop the http.HandlerFunc wrapper. That adapter type exists specifically to bridge the gap between a function and the http.Handler interface. It implements ServeHTTP by calling the function you pass to it.

At runtime, the HTTP server receives a request and calls ServeHTTP on the outer wrapper. Your wrapper records the start time, calls next.ServeHTTP, and waits. The inner handler runs, writes headers and a body, and returns. Only then does your wrapper calculate the elapsed time and print the log line. The chain is synchronous by default. If the inner handler blocks for ten seconds, the log line waits ten seconds. That behavior is usually exactly what you want. You log the actual wall-clock time the client experienced.

Go convention favors explicit interfaces over base types. You accept http.Handler (the interface) and return http.Handler. You never accept a concrete *http.ServeMux in middleware unless you have a specific reason. This keeps your middleware compatible with any router or framework that respects the standard library interface. The community mantra is simple: accept interfaces, return structs. Middleware follows that rule by accepting the handler interface and returning a wrapped handler.

Trust the interface. Wrap the behavior, not the type.

Capturing status codes and request IDs

Production logging needs more than method and path. You want the HTTP status code, a unique request identifier, and structured output. The standard http.ResponseWriter does not expose the status code after WriteHeader is called. You need to wrap it in a custom type that intercepts the call and saves the code for later.

Here is a status-capturing response writer. It satisfies the http.ResponseWriter interface while storing the final status code.

package main

import (
	"net/http"
)

// statusRecorder wraps http.ResponseWriter to capture the status code.
type statusRecorder struct {
	http.ResponseWriter
	status int
}

// WriteHeader overrides the default to record the status code.
func (r *statusRecorder) WriteHeader(code int) {
	r.status = code
	// Delegate to the underlying writer to actually send headers.
	r.ResponseWriter.WriteHeader(code)
}

// GetStatus returns the captured status code, defaulting to 200.
func (r *statusRecorder) GetStatus() int {
	if r.status == 0 {
		return 200
	}
	return r.status
}

Now the middleware can use the recorder instead of the raw response writer. It also generates a request ID and attaches it to the request context so downstream handlers can use it.

package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"time"
)

// requestIDKey holds the context key for the request identifier.
type requestIDKey struct{}

// EnhancedLoggingMiddleware adds status codes, duration, and request IDs.
func EnhancedLoggingMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Generate a unique identifier for this request.
		reqID := fmt.Sprintf("%d-%d", time.Now().UnixNano(), r.RemoteAddr)
		// Attach the ID to the request context for downstream handlers.
		ctx := context.WithValue(r.Context(), requestIDKey{}, reqID)
		r = r.WithContext(ctx)

		// Wrap the response writer to capture the status code.
		rec := &statusRecorder{ResponseWriter: w, status: 200}
		start := time.Now()

		// Execute the next handler with the modified request and recorder.
		next.ServeHTTP(rec, r)

		// Log structured output after the request completes.
		log.Printf("[%s] %s %s %d %v", reqID, r.Method, r.URL.Path, rec.GetStatus(), time.Since(start))
	})
}

Context propagation follows a strict convention in Go. context.Context always travels as the first parameter in function signatures, and it is conventionally named ctx. When you attach data to the context, you pass the enriched request down the chain. Downstream handlers extract the ID with ctx.Value(requestIDKey{}). This keeps tracing data available without polluting function signatures with extra parameters. Receiver names in Go are usually one or two letters matching the type. (r *statusRecorder) follows that convention. Do not use this or self. Let the type name do the heavy lifting.

Context is plumbing. Run it through every long-lived call site.

Where things go wrong

Middleware looks simple until it breaks. The most common mistake is forgetting to call next.ServeHTTP. The compiler will not catch it. The server will hang, the client will time out, and your logs will show nothing. Always verify the inner handler is invoked.

Another trap is blocking on slow I/O. If your logging middleware writes to a remote syslog server or a database without buffering, every HTTP request waits for the log write to complete. A network blip turns into a server-wide stall. Write logs to a buffered channel or use an async logger in production. Synchronous logging is fine for local development, but it becomes a bottleneck under load. Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path if you spawn background log workers.

You will also run into type mismatches if you try to pass the wrong types. The compiler complains with cannot use rec (variable of type *statusRecorder) as http.ResponseWriter in argument if your custom writer misses a required method like Flush or Pusher. Implement the full interface or use a library that handles it. The standard library interface is small, but it grows when you need HTTP/2 push support.

Error handling in Go favors explicit returns. The if err != nil { return err } pattern looks verbose, but it makes failure paths visible. Middleware rarely returns errors directly to the client. Instead, it logs them and calls http.Error(w, "internal server error", 500). Keep the error handling close to where the failure happens. Do not swallow errors in the middleware layer. The community accepts the boilerplate because it makes the unhappy path visible.

The worst middleware bug is the one that never logs.

Choosing the right approach

Use standard library middleware when you need lightweight request logging, timing, or basic context enrichment without external dependencies. Use a router built-in middleware system when you are already using a framework like Echo or Gin and want centralized route registration. Use a structured logging library like slog or zap when you need JSON output, log levels, and field sampling for high-throughput services. Use plain handlers without middleware when you are building a local tool or a single-endpoint service where observability is not a requirement.

Where to go next