The logging bottleneck
You deploy a Go service. It handles requests fine for a week. Then traffic spikes, latency jumps, and the dashboard shows errors. You check the logs. They're a wall of unstructured text, timestamps are missing, or the JSON is malformed. You spend an hour grepping for a trace ID that doesn't exist. This happens when logging is an afterthought.
Go gives you log in the standard library, which is fine for scripts. Real services need structure, performance, and context. The ecosystem offers three heavyweights: slog, zap, and zerolog. They solve the same problem with different trade-offs. slog is the new standard library option. zap is the performance king from Uber. zerolog is the zero-allocation specialist.
Structured logging turns logs into data. Treat them like data.
Structured logging basics
Structured logging means every log entry is a data object, not just a string. Instead of "Error connecting to db: timeout", you get {"level": "error", "msg": "connect failed", "component": "db", "duration_ms": 5000}. Machines can parse this. You can query logs by field. You can aggregate metrics.
The trade-off is usually performance. Formatting strings and marshaling JSON costs CPU cycles. In a hot path, logging can become the bottleneck. The fmt package uses reflection and allocates memory for every format call. Allocations trigger garbage collection. GC pauses stop the world. In a latency-sensitive service, GC pauses kill p99 latency.
Loggers optimize for different goals. slog prioritizes simplicity and standard library integration. zap prioritizes speed with a typed API. zerolog prioritizes zero allocations with a builder pattern.
Minimal examples
Here's the simplest comparison. Each library wants to log a message with a field.
slog uses a handler to control output format. The handler writes structured logs as human-readable text or JSON.
package main
import (
"log/slog"
"os"
)
func main() {
// NewTextHandler writes structured logs as human-readable text.
// The second argument is options; nil uses defaults.
handler := slog.NewTextHandler(os.Stdout, nil)
logger := slog.New(handler)
// Log a message with a key-value pair.
// slog expects alternating key and value arguments.
logger.Info("server started", "port", 8080)
}
zap creates a logger optimized for speed. It uses a syncer to write to stdout and provides typed field constructors.
package main
import (
"go.uber.org/zap"
)
func main() {
// NewProduction creates a logger optimized for speed.
// It uses a syncer to write to stdout.
logger, _ := zap.NewProduction()
defer logger.Sync()
// Info logs a message with structured fields.
// zap.String creates a typed field for type safety.
logger.Info("server started", zap.String("port", "8080"))
}
zerolog uses a fluent builder pattern. Each method returns the logger for chaining, and the final call serializes the output.
package main
import (
"github.com/rs/zerolog/log"
)
func main() {
// zerolog uses a fluent builder pattern.
// Each method returns the logger for chaining.
log.Info().
Str("port", "8080").
Msg("server started")
}
Under the hood: performance and allocations
Performance matters when logging happens in tight loops or high-throughput handlers. The difference comes down to how the logger builds the output.
slog builds a record and passes it to a handler. The handler formats the output. This involves some allocation for the record and the formatting buffer. slog is fast enough for most services. It avoids reflection by using type switches in the handler. The overhead is low, but not zero.
zap uses a two-phase approach. You build a logger once, then use it. It avoids reflection and minimizes allocations. zap has a strict API where you must use typed field constructors like zap.String or zap.Int. This allows the compiler to check types and the logger to skip formatting work. zap also has a SugarLogger that accepts fmt-style arguments for convenience, but it falls back to slower formatting. The convention is to use the strict API in performance-critical code and the sugar API only for quick debugging.
zerolog takes allocation avoidance further. It writes directly to a buffer. You chain methods to append fields. When you call Msg, it serializes the buffer to JSON. No intermediate objects. zerolog reuses buffers from a pool. This makes it the fastest logger in benchmarks, especially for small log entries. The trade-off is the API. You must chain methods and call Msg or Send. Forgetting the final call means nothing logs.
Allocations cost CPU. GC costs latency. Measure before you optimize.
Realistic usage: context and middleware
Real code runs in handlers. You need context propagation. Trace IDs, user IDs, request IDs. Middleware enriches the logger, and handlers use the enriched logger.
slog encourages passing loggers via context. The With method returns a new logger instance. It does not mutate the original. This is safe for concurrent use. The convention is to attach the logger to the context in middleware, then extract it in handlers.
package main
import (
"context"
"log/slog"
"net/http"
"os"
)
// HandleRequest demonstrates logging within an HTTP handler.
// It extracts the logger from context and adds request-specific fields.
func HandleRequest(w http.ResponseWriter, r *http.Request) {
// Context carries the logger instance.
// This pattern allows middleware to enrich logs.
ctx := r.Context()
logger := slog.Default()
// With creates a new logger with persistent fields.
// These fields appear in every log entry from this logger.
reqLogger := logger.With(
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
)
// Log the start of processing.
// InfoContext respects context cancellation.
reqLogger.InfoContext(ctx, "request received")
// Simulate work.
w.WriteHeader(http.StatusOK)
}
zap handles context differently. You usually attach the logger to the context as a value. zap provides zap.Stringer and other helpers for custom types. The With method on zap also returns a new logger. zap is often used with a global logger in smaller services, but context propagation is the robust pattern for microservices.
zerolog attaches the logger to context similarly. The builder pattern works well with middleware. You can chain fields in middleware and pass the logger down. zerolog has a Context method that returns a context with the logger attached.
Context carries the logger. Middleware enriches the logger. Handlers use the logger.
Pitfalls and compiler checks
Each logger has specific pitfalls. Knowing them saves debugging time.
slog requires an even number of arguments for key-value pairs. If you pass an odd number, the runtime panics with slog: even number of arguments wanted. This is a runtime check, not a compile-time check. The compiler won't stop you. You must be careful with variadic arguments.
zap checks types at compile time. If you pass a string where zap.Int is expected, you get a type mismatch error. The compiler complains with cannot use 'value' (untyped string constant) as int value in argument. This is a strength. zap also requires calling Sync on the logger to flush buffers. If you don't call Sync, buffered logs might be lost on crash. The compiler won't stop you. You get silent data loss.
zerolog is flexible but less type-safe. You can pass any value to Interface or Any. The logger will try to serialize it. If serialization fails, the field might be dropped or malformed. zerolog also requires the final Msg or Send call. If you chain fields but forget the final call, nothing logs. The compiler might warn about unused result, but it's easy to miss in complex chains.
Loggers hide bugs. Verify your output format matches your pipeline.
Decision matrix
Pick the logger that matches your bottleneck. Most services don't need zero-allocation logging.
Use slog when you want standard library support and don't need extreme performance. Use slog when your team prefers fewer dependencies and the API is simple enough for most workloads. Use slog when you need to swap handlers dynamically for different output formats. Use slog when you are starting a new project and want to stick to the standard library.
Use zap when performance is the priority and you can tolerate a slightly more verbose API. Use zap when you need a battle-tested logger with extensive middleware and ecosystem support. Use zap when your service handles high throughput and logging overhead matters. Use zap when you want compile-time type safety for log fields.
Use zerolog when you need zero-allocation logging in the hottest paths. Use zerolog when your application is extremely sensitive to memory pressure or GC pauses. Use zerolog when you prefer a fluent builder pattern over key-value arguments. Use zerolog when benchmarks show logging is the bottleneck and zap is not fast enough.