DEV Community

Ayi NEDJIMI
Ayi NEDJIMI

Posted on

Zap vs slog vs zerolog: Go logging libraries compared

Choosing a logging library for your Go service sounds trivial until you're debugging a latency spike at 2 AM and your logs are consuming 15% of the CPU budget. The three dominant options—uber-go/zap, the standard library log/slog, and rs/zerolog—all get the job done, but they make very different trade-offs around performance, API ergonomics, and ecosystem fit. This guide cuts through the noise with working code and concrete production advice.

What structured logging actually means in Go

"Structured logging" means emitting log lines as key-value pairs (typically JSON) instead of free-form strings. The difference matters when your logs flow into a SIEM, a log aggregator like Loki or Datadog, or anything that needs to query on specific fields.

A naive fmt.Printf("user %d login failed", userID) is fast to write but a nightmare to parse at scale. A structured equivalent—logger.Error("login failed", "user_id", userID, "ip", ip)—is machine-readable without regex and plays well with every log aggregation tool built in the last decade.

The three contenders

uber-go/zap was designed from the start for zero-allocation hot paths. The zap.Logger type is type-safe and explicit; the SugaredLogger wrapper trades a small amount of performance for a more ergonomic API. It has been running Uber's production infrastructure for years and the ecosystem around it is mature.

log/slog landed in Go 1.21 and is now part of the standard library. Its design is heavily inspired by zap but uses Go's interface model: you get a slog.Logger backed by a slog.Handler you can swap out. No third-party import, no license review, no dependency maintenance.

rs/zerolog uses a method-chaining API that builds the JSON object inline and only allocates on the final Msg() call. It has the smallest footprint of the three and is very fast in the common case.

Benchmarks: allocations matter more than nanoseconds

Raw benchmark numbers vary by machine, but the allocation profile is stable. All three libraries aim for zero allocations on the hot path. The differences appear in the convenience APIs.

package main

import (
    "os"
    "time"

    "go.uber.org/zap"
    "go.uber.org/zap/zapcore"
    "github.com/rs/zerolog"
    "log/slog"
)

func benchmarkZap() {
    encoderCfg := zapcore.EncoderConfig{
        TimeKey:     "ts",
        LevelKey:    "level",
        MessageKey:  "msg",
        EncodeTime:  zapcore.EpochTimeEncoder,
        EncodeLevel: zapcore.LowercaseLevelEncoder,
    }
    core := zapcore.NewCore(
        zapcore.NewJSONEncoder(encoderCfg),
        zapcore.AddSync(os.Stdout),
        zapcore.InfoLevel,
    )
    logger := zap.New(core)
    defer logger.Sync()

    logger.Info("request processed",
        zap.String("method", "GET"),
        zap.String("path", "/api/v1/users"),
        zap.Int("status", 200),
        zap.Duration("latency", 12*time.Millisecond),
    )
}

func benchmarkZerolog() {
    logger := zerolog.New(os.Stdout).With().Timestamp().Logger()
    logger.Info().
        Str("method", "GET").
        Str("path", "/api/v1/users").
        Int("status", 200).
        Dur("latency", 12*time.Millisecond).
        Msg("request processed")
}

func benchmarkSlog() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    logger.Info("request processed",
        "method", "GET",
        "path", "/api/v1/users",
        "status", 200,
        "latency", 12*time.Millisecond,
    )
}
Enter fullscreen mode Exit fullscreen mode

Running go test -bench=. -benchmem on these three patterns: zap with typed fields and zerolog with chaining both achieve ~0 allocs/op on the core path. slog is close but the any-typed key-value pairs cause one allocation per attribute when using the default handler. Fix it with slog.Attr and LogAttrs() instead of the variadic key-value form.

API ergonomics in practice

Zerolog wins for conciseness if you accept the chaining style. You build a context of base fields with With(), then attach per-call fields in the chain before calling Msg(). It is opinionated but clean once you internalize it. Context propagation is first-class: zerolog.Ctx(ctx) pulls the logger stored in the request context with no boilerplate.

// zerolog: request-scoped logger via context
func withRequestLogger(ctx context.Context, requestID string) context.Context {
    logger := zerolog.Ctx(ctx).With().Str("request_id", requestID).Logger()
    return logger.WithContext(ctx)
}

func handler(ctx context.Context) {
    log := zerolog.Ctx(ctx)
    log.Info().Str("action", "fetch_user").Msg("starting db call")
}
Enter fullscreen mode Exit fullscreen mode

slog wins for ecosystem integration. Because it lives in the standard library, third-party packages can accept a *slog.Logger without expanding your dependency graph. The slog.Handler interface makes adapters trivial—route logs to OpenTelemetry, Datadog, or a custom sink without forking anything.

Zap wins on control. The zapcore.Core interface gives you finer-grained control over encoding, sampling, and multi-sink routing than either alternative. Uber's zapcore.NewSamplerWithOptions is the most production-tested path for log sampling (e.g., only emit 1% of info-level logs during high traffic), which matters in high-throughput services.

Operational traps to avoid

A few things that reliably trip teams up in production:

1. Forgetting defer logger.Sync() with zap. The buffered writer will not flush on process exit. The last 2-3 seconds of logs disappear on SIGTERM—a particularly bad time to lose data.

2. Zerolog's .Interface() breaks zero-alloc. Zerolog's chain looks allocation-free, but passing any non-primitive type via .Interface() allocates. Use typed methods (.Str(), .Int(), .Dur()) on hot paths and profile before assuming you're allocation-free.

3. slog context propagation is manual. Unlike zerolog, slog has no built-in context.Context support. You need middleware that stores a pre-configured logger (with request ID, trace ID, etc.) in the context. OpenTelemetry's bridge handles this if you're already on OTEL; otherwise a three-line helper covers it.

4. Global logger state in zap. zap.L() and zap.ReplaceGlobals() are goroutine-safe but make tests painful. Inject loggers explicitly through constructors or function parameters rather than relying on the global.

From a security operations standpoint, structured logging matters beyond developer ergonomics. When logs feed a detection pipeline, unparsed messages mean missed signals. The security hardening checklists we publish include a logging configuration section precisely because misconfigured log outputs—wrong level, missing fields, unstructured format—show up repeatedly in assessments.

The takeaway

There is no universally correct answer, but the decision tree is short:

  • Starting a new service today? Use slog. It is in stdlib, fast enough for almost everything, and your dependencies can accept it without adding their own logging import.
  • High-throughput service where allocations matter? Use zap with typed fields, never the Sugared API on the hot path.
  • Small service, zero external deps, opinionated API is fine? Use zerolog. The chaining API is clean and context propagation is first-class.

Whichever you pick: output JSON, attach a request ID and trace ID from context on every line, and run at INFO in production unless you are actively debugging. The choice of library is secondary to the discipline of what you put in the log.


I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.

Top comments (0)