golang.
golang52 min read

Migrating Go structured logging from zap and zerolog to log/slog — a 6-step guide

Migrating Go structured logging from zap and zerolog to log/slog — a 6-step guide

Step 1: Building a Dual-Logger Baseline with Zap and Zerolog

Before migrating to log/slog, you need an honest "before" picture: a real Go application running two competing structured-logging libraries simultaneously. This situation is extremely common in older codebases — one team adopted Uber's zap for raw throughput, another adopted rs/zerolog because the chaining API felt nicer, and now both live in the same binary. Each carries its own configuration, its own field syntax, and its own opinion about what the message key should be called.

In this step we build that baseline deliberately. We create a small Go module, pin Go 1.22, define a tiny domain (Order processor), instrument the same call path twice, and write tests that lock in the current awkward behaviour. We are intentionally not introducing slog yet — the whole point of step 1 is to make the duplication painful and visible so the migration in later steps has something concrete to delete.

Setup

We need a Go module, two logging dependencies, and a directory layout that separates logger constructors from the domain code that uses them:

codebase/
├── cmd/
│   └── baseline/
│       └── main.go        # entry point that runs the dual-logger demo
├── internal/
│   ├── applog/
│   │   └── applog.go      # constructors for zap + zerolog
│   └── order/
│       ├── order.go       # domain: Order + Processor
│       └── order_test.go  # locks in the dual-emit behaviour
├── go.mod
├── go.sum
└── Makefile

The module declaration and direct dependencies live in go.mod:

module github.com/vytharion/go-slog-migration-zap-zerolog-handler

go 1.22

require (
	github.com/rs/zerolog v1.33.0
	go.uber.org/zap v1.27.0
)

go 1.22 is intentional — log/slog landed in Go 1.21, but pinning 1.22 gives us slog.SetLogLoggerLevel and the maturity fixes that make the migration in later steps less surprising. We only list zap and zerolog as direct dependencies; everything else (go-colorable, go-isatty, multierr) flows in as transitive dependencies and go mod tidy manages them.

A short Makefile keeps everyday commands close at hand:

.PHONY: test baseline tidy

test:
	go test ./...

baseline:
	go run ./cmd/baseline

tidy:
	go mod tidy

Implementation

The applog package is the seam we will eventually replace. Today it knows about two libraries; in later steps it collapses into one. Both constructors accept an io.Writer so tests can capture output into a buffer rather than polluting stdout.

package applog

import (
	"io"

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

func NewZap(w io.Writer) *zap.Logger {
	encoderCfg := zap.NewProductionEncoderConfig()
	encoderCfg.TimeKey = "ts"
	encoderCfg.EncodeTime = zapcore.EpochTimeEncoder
	encoderCfg.EncodeLevel = zapcore.LowercaseLevelEncoder

	core := zapcore.NewCore(
		zapcore.NewJSONEncoder(encoderCfg),
		zapcore.AddSync(w),
		zapcore.InfoLevel,
	)
	return zap.New(core)
}

func NewZerolog(w io.Writer) zerolog.Logger {
	return zerolog.New(w).Level(zerolog.InfoLevel)
}

A few design choices here that will matter during the migration. We swap zap's default time key to ts and use a lowercase level encoder so "info" matches what zerolog emits — even with that alignment, the two libraries still disagree on the message key (msg vs message). That disagreement is precisely the kind of problem a unified slog handler will resolve. zerolog.New returns a value, not a pointer; that is the library's idiomatic style and we keep it. Neither constructor touches a global — no zap.L(), no log.Logger — which is what makes the migration mechanical rather than archaeological.

The domain lives in internal/order/order.go. The Processor holds both loggers and emits through both on every successful Handle call:

package order

import (
	"errors"

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

type Order struct {
	ID       string
	Customer string
	Amount   int
}

type Processor struct {
	zapLogger *zap.Logger
	zlogger   zerolog.Logger
}

func NewProcessor(zapLogger *zap.Logger, zlogger zerolog.Logger) *Processor {
	return &Processor{zapLogger: zapLogger, zlogger: zlogger}
}

func (p *Processor) Handle(o Order) error {
	if o.ID == "" {
		return errors.New("order id is required")
	}

	p.zapLogger.Info(
		"order received",
		zap.String("order_id", o.ID),
		zap.String("customer", o.Customer),
		zap.Int("amount", o.Amount),
	)

	p.zlogger.Info().
		Str("order_id", o.ID).
		Str("customer", o.Customer).
		Int("amount", o.Amount).
		Msg("order received")

	return nil
}

This is the duplication we are going to delete. zap uses typed field constructors (zap.String, zap.Int); zerolog uses a fluent chain (.Str(...).Int(...).Msg(...)). The field values are identical — only the syntax differs. A unified slog handler will let us write the call site once and route it to whichever backend we keep.

The tests in internal/order/order_test.go pin three things: both loggers actually emit, validation failures emit nothing (so unvalidated input never reaches the log), and the message keys are deliberately different between the two libraries. That last assertion is the one that will change during the migration — when both call sites converge through slog, they will agree on a single message key.

Finally, cmd/baseline/main.go wires everything together and processes a few sample orders:

func main() {
	zapLogger := applog.NewZap(os.Stdout)
	defer zapLogger.Sync()
	zlogger := applog.NewZerolog(os.Stdout)

	p := order.NewProcessor(zapLogger, zlogger)

	orders := []order.Order{
		{ID: "O-1001", Customer: "alice", Amount: 4200},
		{ID: "O-1002", Customer: "bob", Amount: 199},
		{ID: "O-1003", Customer: "carol", Amount: 7800},
	}

	for _, o := range orders {
		if err := p.Handle(o); err != nil {
			log.Fatalf("handle %s: %v", o.ID, err)
		}
	}
}

Verification

Run the test suite first:

go test ./... -count=1
?   	github.com/vytharion/go-slog-migration-zap-zerolog-handler/cmd/baseline	[no test files]
?   	github.com/vytharion/go-slog-migration-zap-zerolog-handler/internal/applog	[no test files]
ok  	github.com/vytharion/go-slog-migration-zap-zerolog-handler/internal/order	0.223s

The ? markers are expected — cmd/baseline is a main package and applog is just constructors. The behaviour we care about lives in the order package and that passes.

Now run the baseline binary to see both loggers writing to stdout simultaneously:

go run ./cmd/baseline
{"level":"info","ts":1779983170.228868,"msg":"order received","order_id":"O-1001","customer":"alice","amount":4200}
{"level":"info","order_id":"O-1001","customer":"alice","amount":4200,"message":"order received"}
{"level":"info","ts":1779983170.229001,"msg":"order received","order_id":"O-1002","customer":"bob","amount":199}
{"level":"info","order_id":"O-1002","customer":"bob","amount":199,"message":"order received"}
{"level":"info","ts":1779983170.229014,"msg":"order received","order_id":"O-1003","customer":"carol","amount":7800}
{"level":"info","order_id":"O-1003","customer":"carol","amount":7800,"message":"order received"}

Look closely at any adjacent pair of lines — they describe the same event but disagree on three things. zap writes a ts field with an epoch float; zerolog writes no timestamp at all in this default configuration. zap names the human-readable text msg; zerolog names it message. Field order differs because each library serialises in its own sequence. A downstream log pipeline — Loki, ELK, Datadog — must understand both shapes to parse this binary's output. That is the operational cost of the duplication, and it is the cost the migration will eliminate.

What we built

A minimal but realistic Go module with two structured loggers wired into the same call path, a passing test that locks in the dual-emit behaviour, and a make baseline target that prints both log shapes side by side so the divergence is impossible to overlook. Every later step in this series has a concrete "before" state to compare against — and a concrete test suite to keep green while the implementation underneath changes.

Repository

The companion code for this article: https://github.com/vytharion/go-slog-migration-zap-zerolog-handler

The state of the code after this step: ffd6af4

Key commits to step through:

  • ffd6af4 — step 1: baseline app with zap and zerolog side by side

Step 2: Introducing log/slog — Logger, Handler, and Record

In step 1 we stood up a Go service that emits structured logs through both zap and rs/zerolog, making the duplication explicit and visible. This step adds the third logger that will eventually replace both: log/slog, the structured logging package that became part of the standard library in Go 1.21. We are not swapping anything out yet.

The goal is to introduce slog alongside the two existing loggers, observe what its default JSON output looks like next to zap and zerolog, and — more importantly — understand the three abstractions the package is built on. The Logger is the front-end developers call from business code. The Handler is the pluggable back-end that decides how a structured event becomes bytes on a writer. The Record is the immutable struct that travels between them, carrying the timestamp, level, message, and attributes.

Almost every interesting decision in later steps — routing slog calls through a zap core, deduplicating attribute keys, wrapping a zerolog Event — is a decision about one of those three types. We name them now, write a focused program that exercises each one, and pin the behaviour with tests. By the end of this step the baseline binary prints three log lines per order instead of two, and we can inspect a real slog.Record value in a test and read its fields directly.

Setup

There are no new third-party dependencies. log/slog is in the standard library starting at Go 1.21 and we already pinned go 1.22 in step 1, so the import path "log/slog" is available without touching go.mod. The files we change or add this step are:

codebase/
├── cmd/
│   └── baseline/
│       └── main.go              # now constructs a slog.Logger too
├── internal/
│   ├── applog/
│   │   ├── applog.go            # +NewSlog constructor
│   │   └── applog_test.go       # NEW: slog + Handler + Record probes
│   └── order/
│       ├── order.go             # Processor now holds a *slog.Logger too
│       └── order_test.go        # extended to cover the slog call path

The seam from step 1 stays unchanged: every constructor takes an io.Writer, every logger is injected, and nothing reaches for a package-level global. That discipline is what lets the tests capture output into a bytes.Buffer without any os.Setenv calls across the suite.

Implementation

We start with the constructor. slog ships two handlers in the standard library: slog.TextHandler (the human-friendly key=value format) and slog.NewJSONHandler (machine-readable JSON). For a service that already coexists with zap and zerolog — both of which produce JSON — the JSON handler is the obvious choice. It keeps the side-by-side diff in the baseline binary apples-to-apples.

// internal/applog/applog.go (additions)
import (
    "io"
    "log/slog"
)

func NewSlog(w io.Writer) *slog.Logger {
    handler := slog.NewJSONHandler(w, &slog.HandlerOptions{Level: slog.LevelInfo})
    return slog.New(handler)
}

Three decisions here, each of which reappears in later steps:

  • *slog.HandlerOptions{Level: slog.LevelInfo}slog separates the level filter from the handler implementation. The level lives on HandlerOptions, which any standard-library handler accepts. We hardcode Info to match the configuration from step 1; later steps make this configurable.
  • slog.New(handler)slog.New is a thin wrapper that stores a handler pointer on a Logger. When you call logger.Info(...), the Logger builds a slog.Record, asks handler.Enabled(ctx, level), and if the answer is true it calls handler.Handle(ctx, record). That dispatch is the entire reason slog is portable: any backend satisfying the Handler interface can sit behind the same Logger value.
  • No global. We deliberately do not call slog.SetDefault(...). The default logger is a global mutable singleton — tests want isolation, production wants determinism. Injecting *slog.Logger is the same discipline we already follow for zap and zerolog.

Next, the domain. Processor now holds all three loggers and Handle emits the same event through each. The duplication is intentional — we are establishing the three-way "before" picture that later steps will collapse.

// internal/order/order.go
type Processor struct {
    zapLogger  *zap.Logger
    zlogger    zerolog.Logger
    slogLogger *slog.Logger
}

func NewProcessor(zapLogger *zap.Logger, zlogger zerolog.Logger, slogLogger *slog.Logger) *Processor {
    return &Processor{zapLogger: zapLogger, zlogger: zlogger, slogLogger: slogLogger}
}

func (p *Processor) Handle(o Order) error {
    if o.ID == "" {
        return errors.New("order id is required")
    }

    p.zapLogger.Info(
        "order received",
        zap.String("order_id", o.ID),
        zap.String("customer", o.Customer),
        zap.Int("amount", o.Amount),
    )

    p.zlogger.Info().
        Str("order_id", o.ID).
        Str("customer", o.Customer).
        Int("amount", o.Amount).
        Msg("order received")

    p.slogLogger.Info(
        "order received",
        slog.String("order_id", o.ID),
        slog.String("customer", o.Customer),
        slog.Int("amount", o.Amount),
    )

    return nil
}

Look at the three call sites together. zap uses typed field constructors as variadic arguments. zerolog uses a fluent chain that terminates in .Msg(...). slog looks like zap from a distance — message first, then variadic attr helpers — but the type system underneath differs. Each slog.String("k", "v") returns a slog.Attr, a {Key string; Value slog.Value} pair. The handler walks those attrs through the Record.Attrs(yield) iterator, never through reflection. That distinction is what makes a custom slog handler cheap to write: you walk attrs with a single callback and determine the value's concrete kind from slog.Value.Kind() without any type switching on any.

The part that ties this step to the rest of the series is the three-abstraction probe in applog_test.go. We define a minimal captureHandler that satisfies the slog.Handler interface so we can grab a slog.Record and inspect its fields directly.

type captureHandler struct {
    records []slog.Record
}

func (h *captureHandler) Enabled(_ context.Context, _ slog.Level) bool { return true }

func (h *captureHandler) Handle(_ context.Context, r slog.Record) error {
    h.records = append(h.records, r.Clone())
    return nil
}

func (h *captureHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h }
func (h *captureHandler) WithGroup(_ string) slog.Handler       { return h }

Four methods — that is the entire interface. Enabled is the cheap fast-path filter: the Logger calls it before allocating a Record, so a handler that disables Debug pays almost nothing for logger.Debug(...) call sites in hot paths. Handle is where the record turns into bytes. WithAttrs and WithGroup return a derived handler that pre-binds attributes or namespaces; we will exercise them in later steps when we build a real zap-backed handler. For now they are stub returns of the receiver.

The Record itself is worth a closer look:

rec := cap.records[0]
// rec.Time     -> time.Time set by the Logger right before Handle
// rec.Level    -> slog.LevelInfo
// rec.Message  -> "record probe"
// rec.PC       -> program counter; lets handlers resolve source location
// rec.Attrs(func(a slog.Attr) bool { ... })

Record does not own a writer or a format. It is a value type carrying what happened, decoupled from how it gets serialised. That separation is the core proposition of log/slog: zap and zerolog each glue event description and byte output together inside a single type, while slog splits them — and every future step in this series lives in that split.

cmd/baseline/main.go is updated to construct the new logger and pass it to order.NewProcessor. It now writes three lines per order to stdout, one from each backend.

Verification

Run the unit suite first. There are now tests in both packages.

go test ./... -count=1
?   	github.com/vytharion/go-slog-migration-zap-zerolog-handler/cmd/baseline	[no test files]
ok  	github.com/vytharion/go-slog-migration-zap-zerolog-handler/internal/applog	0.114s
ok  	github.com/vytharion/go-slog-migration-zap-zerolog-handler/internal/order	0.231s

The applog package now has tests (it was [no test files] in step 1) because we added probes for the JSON handler, the level filter, and the Record. The order package is still green even though the production code emits a third log line per event — the test is pinning the new behaviour rather than merely tolerating it.

Run the baseline binary and watch all three log lines per order land on stdout:

go run ./cmd/baseline
{"level":"info","ts":1779983410.118772,"msg":"order received","order_id":"O-1001","customer":"alice","amount":4200}
{"level":"info","order_id":"O-1001","customer":"alice","amount":4200,"message":"order received"}
{"time":"2026-05-28T22:50:10.118799+07:00","level":"INFO","msg":"order received","order_id":"O-1001","customer":"alice","amount":4200}
{"level":"info","ts":1779983410.118901,"msg":"order received","order_id":"O-1002","customer":"bob","amount":199}
{"level":"info","order_id":"O-1002","customer":"bob","amount":199,"message":"order received"}
{"time":"2026-05-28T22:50:10.118914+07:00","level":"INFO","msg":"order received","order_id":"O-1002","customer":"bob","amount":199}
{"level":"info","ts":1779983410.118930,"msg":"order received","order_id":"O-1003","customer":"carol","amount":7800}
{"level":"info","order_id":"O-1003","customer":"carol","amount":7800,"message":"order received"}
{"time":"2026-05-28T22:50:10.118942+07:00","level":"INFO","msg":"order received","order_id":"O-1003","customer":"carol","amount":7800}

Each group of three lines is the same event seen through three different lenses. The differences map directly to problems slog was designed to let us solve with a single seam:

  • Time format. zap emits "ts": <float epoch>, zerolog omits the timestamp entirely by default, slog emits "time": "<RFC3339Nano>". A downstream parser consuming this stream has to know all three conventions. A custom slog Handler normalises any of them.
  • Level casing. zap and zerolog print "info", the standard slog.JSONHandler prints "INFO". Trivial to fix once all call sites route through a single handler — impossible to fix uniformly while they are split.
  • Message key. zap uses msg, zerolog uses message, slog uses msg. Three reasonable defaults that disagree with each other. slog is what lets us pick one set and route everyone through it.

None of these are defects in any individual library. They are the expected outcome when three independent libraries each make sensible but incompatible choices.

What we built

The codebase now imports log/slog, exposes a NewSlog constructor that mirrors the shape of NewZap and NewZerolog, and routes the same Order event through all three loggers on every Handle call. New tests pin three properties we rely on in later steps: the JSON handler emits a parseable line with the expected keys, the level filter actually drops Debug output, and a custom slog.Handler receives a slog.Record with Time, Level, Message, and iterable attrs.

Conceptually, we have named the three abstractions — Logger, Handler, Record — that every remaining step refers to. The next step replaces the stdlib JSONHandler with one we write ourselves, so understanding the interface boundary is no longer optional.

Repository

The companion code for this article: https://github.com/vytharion/go-slog-migration-zap-zerolog-handler

The state of the code after this step: 67639bc

Key commits to step through:

  • ffd6af4 — step 1: baseline app with zap and zerolog side by side
  • 67639bc — step 2: introducing log/slog — Logger, Handler, Record

Step 3: Building a Custom slog.Handler That Mirrors zap's JSON Output

Step 2 plugged the standard slog.JSONHandler into the baseline binary and exposed three incompatibilities with zap: levels are upper-cased ("INFO" vs "info"), timestamps come out as RFC3339Nano strings instead of epoch floats, and field order is non-deterministic rather than the strict level → ts → msg → attrs sequence that zap's production encoder enforces. For a greenfield service none of that matters. For a migration off an existing zap deployment — one with live Loki parsers, Grafana dashboards, and on-call playbooks keyed to exact field names and value shapes — it matters a great deal.

This step builds a custom slog.Handler (the zap-style handler) that walks the same slog.Record but serialises it byte-for-byte identically to zap. By the end, applog exposes a second slog.Logger constructor whose output a downstream parser cannot distinguish from zap's, and a full test suite — field order, value kinds, groups, escaping, concurrency, and a direct bytes.Equal check against a live zap.Logger — pins the contract for every step that follows.

Setup

No new module dependencies. Everything new lives under a fresh internal package so the handler stays decoupled from the loggers that consume it:

codebase/
├── cmd/
│   └── baseline/
│       └── main.go                       # demonstrates the zap-style handler at startup
├── internal/
│   ├── applog/
│   │   └── applog.go                     # +NewSlogZapStyle constructor
│   └── zaphandler/
│       ├── handler.go                    # NEW: the custom slog.Handler
│       └── handler_test.go               # NEW: full behavioural test suite

Three conventions are worth stating upfront. The handler lives in its own internal/zaphandler package rather than as a file inside apploghandler_test.go uses package zaphandler_test, which forces it to consume the handler through its exported surface only. No global writer, no global clock: every handler instance takes an io.Writer and a func() time.Time through Options, which is what lets tests pin timestamps to a fixed value without any package-level mutation. And a single *sync.Mutex is allocated once in New; derived handlers from WithAttrs and WithGroup reuse that pointer, so all children sharing the same writer share one lock.

Implementation

The handler is one struct, four interface methods, and a small set of byte-level helpers.

The struct and constructor establish the core fields: writer, shared mutex, level threshold (stored as slog.Leveler so a *slog.LevelVar can be flipped at runtime without rebuilding the handler), clock, and a chain of pre-bound attributes and groups:

type Options struct {
    Level   slog.Leveler
    NowFunc func() time.Time
}

type Handler struct {
    w       io.Writer
    mu      *sync.Mutex
    level   slog.Leveler
    nowFunc func() time.Time
    chain   []chainEntry
}

type chainEntry struct {
    group string
    attrs []slog.Attr
}

func New(w io.Writer, opts *Options) *Handler {
    h := &Handler{
        w:       w,
        mu:      &sync.Mutex{},
        level:   slog.LevelInfo,
        nowFunc: time.Now,
    }
    if opts == nil {
        return h
    }
    if opts.Level != nil {
        h.level = opts.Level
    }
    if opts.NowFunc != nil {
        h.nowFunc = opts.NowFunc
    }
    return h
}

chainEntry is a discriminated union — either group != "" (a WithGroup call) or attrs != nil (a WithAttrs call). Insertion order is preserved deliberately: h.WithGroup("a").WithGroup("b") must serialise as "a":{"b":{...}}, not the reverse.

Enabled is on the hot path — slog.Logger calls it before allocating a Record — so it stays cheap and lock-free:

func (h *Handler) Enabled(_ context.Context, lvl slog.Level) bool {
    return lvl >= h.level.Level()
}

Both derivation methods return a new handler with the chain extended; the parent is never mutated. The cheap no-op paths (WithAttrs with an empty slice, WithGroup with an empty name) return the receiver unchanged:

func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler {
    if len(attrs) == 0 {
        return h
    }
    clone := h.clone()
    clone.chain = append(clone.chain, chainEntry{attrs: attrs})
    return clone
}

func (h *Handler) WithGroup(name string) slog.Handler {
    if name == "" {
        return h
    }
    clone := h.clone()
    clone.chain = append(clone.chain, chainEntry{group: name})
    return clone
}

func (h *Handler) clone() *Handler {
    chainCopy := make([]chainEntry, len(h.chain))
    copy(chainCopy, h.chain)
    return &Handler{
        w:       h.w,
        mu:      h.mu,
        level:   h.level,
        nowFunc: h.nowFunc,
        chain:   chainCopy,
    }
}

clone copies the chain slice explicitly. Reusing the slice header without copying the entries would be a subtle bug — appending to the clone could shadow into the parent if the parent's slice still had spare capacity. The defensive copy makes "child does not leak attrs back into parent" a structural property, not a happy accident.

Handle is the core method. It builds the line into a []byte scratch buffer, opens the brace, writes level, ts, msg in that exact order, walks the pre-bound chain, appends the record's own attrs, closes any opened group braces, then closes the outer brace and appends \n:

func (h *Handler) Handle(_ context.Context, r slog.Record) error {
    ts := r.Time
    if ts.IsZero() {
        ts = h.nowFunc()
    }

    lb := &lineBuilder{}
    lb.buf = append(lb.buf, '{')
    lb.appendKey("level")
    lb.buf = appendRawString(lb.buf, levelString(r.Level))
    lb.needsComma = true
    lb.appendKey("ts")
    lb.buf = appendJSONFloat(lb.buf, epochSeconds(ts))
    lb.needsComma = true
    lb.appendKey("msg")
    lb.buf = appendRawString(lb.buf, r.Message)
    lb.needsComma = true
    // ... chain + attrs ...
    h.mu.Lock()
    defer h.mu.Unlock()
    _, err := h.w.Write(lb.buf)
    return err
}

Field order is the whole point. zap.NewProductionEncoderConfig() writes level, then ts, then msg, then user attrs. Any other order — even with identical key/value pairs — fails bytes.Equal. The order is baked into the call sequence rather than derived from a map, because Go map iteration is intentionally randomised.

The chain walk handles WithAttrs/WithGroup accumulation. A group with no attrs after it must not appear in the output — no "req":{}— so opening a group object is deferred until there are actually attrs to put inside. A pending slice and flush closure implement the deferral:

pending := make([]string, 0, len(h.chain))
opened := 0
flush := func() {
    for _, g := range pending {
        lb.appendKey(g)
        lb.buf = append(lb.buf, '{')
        lb.needsComma = false
        opened++
    }
    pending = pending[:0]
}

for _, entry := range h.chain {
    if entry.group != "" {
        pending = append(pending, entry.group)
        continue
    }
    if len(entry.attrs) == 0 {
        continue
    }
    flush()
    for _, a := range entry.attrs {
        lb.appendAttr(a)
    }
}

if r.NumAttrs() > 0 {
    flush()
    r.Attrs(func(a slog.Attr) bool {
        lb.appendAttr(a)
        return true
    })
}

for i := 0; i < opened; i++ {
    lb.buf = append(lb.buf, '}')
}
lb.buf = append(lb.buf, '}', '\n')

r.Attrs(func(a slog.Attr) bool { ... }) is the canonical iterator over a slog.Record's attrs. The bool return is a "keep going" flag; returning false short-circuits the walk. We always return true.

lineBuilder is a small stateful helper that tracks whether the next key needs a leading comma. Without it, the needsComma flag would thread through every call site manually:

type lineBuilder struct {
    buf        []byte
    needsComma bool
}

func (lb *lineBuilder) appendKey(k string) {
    if lb.needsComma {
        lb.buf = append(lb.buf, ',')
    }
    lb.buf = appendRawString(lb.buf, k)
    lb.buf = append(lb.buf, ':')
    lb.needsComma = false
}

appendAttr resolves the value (a slog.LogValuer defers computation until the handler requests it) and handles the one structurally-recursive case in slog: a KindGroup attr is a list of sub-attrs needing their own braces, except when its key is empty — in which case the group inlines into the surrounding scope. That inlining rule comes from slog's docs and is what makes slog.Group("", ...) useful as an "attach as siblings, not as a nested object" idiom:

func (lb *lineBuilder) appendAttr(a slog.Attr) {
    if a.Key == "" && a.Value.Kind() != slog.KindGroup {
        return
    }
    a.Value = a.Value.Resolve()

    if a.Value.Kind() == slog.KindGroup {
        groupAttrs := a.Value.Group()
        if len(groupAttrs) == 0 {
            return
        }
        if a.Key == "" {
            for _, sub := range groupAttrs {
                lb.appendAttr(sub)
            }
            return
        }
        lb.appendKey(a.Key)
        lb.buf = append(lb.buf, '{')
        lb.needsComma = false
        for _, sub := range groupAttrs {
            lb.appendAttr(sub)
        }
        lb.buf = append(lb.buf, '}')
        lb.needsComma = true
        return
    }

    lb.appendKey(a.Key)
    lb.buf = appendValue(lb.buf, a.Value)
    lb.needsComma = true
}

Value serialisation walks the slog.Kind enum. Each kind has a dedicated path to avoid encoding/json's reflection cost on hot scalar types — strings go through appendRawString, ints through strconv.AppendInt, durations render as seconds (matching zapcore.SecondsDurationEncoder), times collapse to epoch-float format, and only KindAny falls back to json.Marshal:

func appendValue(b []byte, v slog.Value) []byte {
    switch v.Kind() {
    case slog.KindString:
        return appendRawString(b, v.String())
    case slog.KindInt64:
        return strconv.AppendInt(b, v.Int64(), 10)
    case slog.KindUint64:
        return strconv.AppendUint(b, v.Uint64(), 10)
    case slog.KindFloat64:
        return appendJSONFloat(b, v.Float64())
    case slog.KindBool:
        return strconv.AppendBool(b, v.Bool())
    case slog.KindDuration:
        return appendJSONFloat(b, v.Duration().Seconds())
    case slog.KindTime:
        return appendJSONFloat(b, epochSeconds(v.Time()))
    case slog.KindAny:
        return appendAny(b, v.Any())
    }
    return appendAny(b, v.Any())
}

func appendAny(b []byte, v any) []byte {
    if err, ok := v.(error); ok {
        return appendRawString(b, err.Error())
    }
    enc, err := json.Marshal(v)
    if err != nil {
        return appendRawString(b, fmt.Sprintf("!ERROR:%v", err))
    }
    return append(b, enc...)
}

The error special-case matters in practice. json.Marshal on most error types returns {} because the error's fields are unexported. zap has the same special-case under zap.Error; reproducing it here is what keeps drop-in compatibility for the migration.

The subtlest piece is appendRawString. Go's encoding/json escapes <, >, and & to <, >, & by default — a legacy holdover from JSON inlined into HTML <script> blocks. zap's JSON encoder does not escape them, because logs are not HTML. If our handler escapes them and zap does not, the byte-equality test fails on any message containing those characters. The string writer is hand-rolled with zap's exact rules: backslash and double-quote get \-prefixed, \n/\r/\t get short escapes, control bytes under 0x20 become \u00XX, and everything else — including <>& — passes through unchanged:

const hexChars = "0123456789abcdef"

func appendRawString(b []byte, s string) []byte {
    b = append(b, '"')
    for i := 0; i < len(s); i++ {
        c := s[i]
        if c >= 0x20 && c != '\\' && c != '"' {
            b = append(b, c)
            continue
        }
        switch c {
        case '\\', '"':
            b = append(b, '\\', c)
        case '\n':
            b = append(b, '\\', 'n')
        case '\r':
            b = append(b, '\\', 'r')
        case '\t':
            b = append(b, '\\', 't')
        default:
            b = append(b, '\\', 'u', '0', '0', hexChars[c>>4], hexChars[c&0xf])
        }
    }
    return append(b, '"')
}

The tail of the file is two small helpers. appendJSONFloat handles NaN and ±Inf the same way zap does — emitting them as quoted strings rather than bare JSON literals that most parsers reject. levelString maps slog.Level constants to debug/info/warn/error lowercase, clamping anything past Error to "error".

The test suite in internal/zaphandler/handler_test.go covers field order (TestHandleEmitsZapFieldOrder), every slog.Kind serialiser (TestHandleSerializesAllSlogValueKinds), level filtering directly and through a slog.Logger (TestEnabledRespectsLevel), the four level strings (TestLevelStrings), WithAttrs ordering and parent isolation (TestWithAttrsPrebindsFields), nested WithGroup semantics (TestWithGroupNamespacesSubsequentAttrs, TestWithGroupEmptyGroupOmitted, TestNestedGroupsProduceNestedObjects), the empty-key inlined-group rule (TestGroupValueAttrInlinedWhenKeyEmpty), string escaping including the no-HTML-escape requirement (TestStringEscapingMatchesZapNotHTMLEscape), and concurrent writers (TestHandleIsConcurrentSafe). The keystone test is TestHandlerOutputMatchesZapByteForByte — it constructs a real zap.Logger with the production JSON encoder, emits the same record through both, normalises the timestamp to a placeholder so wall-clock drift does not matter, and compares with bytes.Equal.

Finally, applog gains a thin constructor and cmd/baseline/main.go calls it at startup:

// internal/applog/applog.go (addition)
func NewSlogZapStyle(w io.Writer) *slog.Logger {
    handler := zaphandler.New(w, &zaphandler.Options{Level: slog.LevelInfo})
    return slog.New(handler)
}
// cmd/baseline/main.go (excerpt)
zapStyle := applog.NewSlogZapStyle(os.Stdout)
zapStyle.Info("custom handler ready",
    slog.String("step", "3"),
    slog.String("backend", "zap-style"),
)

Verification

Run the test suite. The new zaphandler package contributes a dozen tests, all of which must pass — especially the byte-equality one, which is the contract the rest of the migration leans on:

go test ./... -count=1
?   	github.com/vytharion/go-slog-migration-zap-zerolog-handler/cmd/baseline	[no test files]
ok  	github.com/vytharion/go-slog-migration-zap-zerolog-handler/internal/applog	0.118s
ok  	github.com/vytharion/go-slog-migration-zap-zerolog-handler/internal/order	0.235s
ok  	github.com/vytharion/go-slog-migration-zap-zerolog-handler/internal/zaphandler	0.342s

The zaphandler line is the new one. The applog and order packages stay green because nothing in their existing call paths changed — NewSlogZapStyle is purely additive.

Run the baseline binary. The first line is the zap-style handler firing at startup; the three groups of three that follow are the unchanged dual-emit-plus-slog-default output from step 2:

go run ./cmd/baseline
{"level":"info","ts":1779983890.142117,"msg":"custom handler ready","step":"3","backend":"zap-style"}
{"level":"info","ts":1779983890.142312,"msg":"order received","order_id":"O-1001","customer":"alice","amount":4200}
{"level":"info","order_id":"O-1001","customer":"alice","amount":4200,"message":"order received"}
{"time":"2026-05-28T22:58:10.142339+07:00","level":"INFO","msg":"order received","order_id":"O-1001","customer":"alice","amount":4200}
{"level":"info","ts":1779983890.142371,"msg":"order received","order_id":"O-1002","customer":"bob","amount":199}
{"level":"info","order_id":"O-1002","customer":"bob","amount":199,"message":"order received"}
{"time":"2026-05-28T22:58:10.142388+07:00","level":"INFO","msg":"order received","order_id":"O-1002","customer":"bob","amount":199}
{"level":"info","ts":1779983890.142401,"msg":"order received","order_id":"O-1003","customer":"carol","amount":7800}
{"level":"info","order_id":"O-1003","customer":"carol","amount":7800,"message":"order received"}
{"time":"2026-05-28T22:58:10.142416+07:00","level":"INFO","msg":"order received","order_id":"O-1003","customer":"carol","amount":7800}

Compare the first line with the second. Both start with {"level":"info","ts":<float>,"msg":"...","..., both use msg not message, both lowercase the level, both render the timestamp as an epoch float, and both place user attrs at the tail in insertion order. The first line came from a slog.Logger; the second from a zap.Logger. A downstream parser tuned for zap cannot distinguish them, and TestHandlerOutputMatchesZapByteForByte keeps that property locked in.

The third and fourth lines per group are the zerolog and stdlib slog.JSONHandler outputs from step 2, still in the stream as a visible reminder of what the migration eventually removes.

What we built

A self-contained internal/zaphandler package whose Handler is a complete slog.Handler implementation that emits JSON byte-for-byte identical to zap's production encoder. The package has a behavioural test suite covering field order, every slog.Kind, level filtering, group nesting, the empty-key inlining rule, zap-style string escaping, concurrent writes under a shared mutex, and a head-to-head bytes.Equal comparison against a real zap.Logger. The applog package now exposes NewSlogZapStyle so any caller can switch from the stdlib JSON handler to the zap-style one with a one-line change. With this in place, the next step can start retargeting actual call sites: swap zap.Logger.Info(...) for a *slog.Logger backed by the zap-style handler, and the bytes on the wire stay identical.

Repository

The companion code for this article: https://github.com/vytharion/go-slog-migration-zap-zerolog-handler

The state of the code after this step: 4ae957c

Key commits to step through:

  • ffd6af4 — step 1: baseline app with zap and zerolog side by side
  • 67639bc — step 2: introducing log/slog — Logger, Handler, Record
  • 4ae957c — step 3: custom slog.Handler that mirrors zap's JSON output

Step 4: Building a zerolog-shaped facade over slog

Step 3 gave us a custom slog.Handler that emits bytes indistinguishable from zap's production encoder, so the zap side of the codebase has a credible migration path: change the logger type at the seam, leave the call sites alone. The zerolog side has a different problem.

Zerolog's public API is a fluent chain — log.Info().Str("k", "v").Int("n", 7).Msg("done") — and that chain is baked into hundreds of call sites throughout the business code. There is no slog API that looks like that out of the box. slog.Logger.Info("msg", slog.String("k","v"), slog.Int("n",7)) is a flat variadic call, not a builder, and rewriting every chained call site by hand is exactly the kind of big-bang change that gets a migration cancelled at the first review meeting.

This step builds the bridge: a thin adapter package called slogzero whose surface mirrors the slice of zerolog's API the codebase actually uses — Logger, Context, and Event, plus Debug/Info/Warn/Error, Str/Int/Int64/Float64/Bool/Dur/Time/Any/Err, and the terminal Msg/Msgf/Send. The adapter accumulates slog.Attr values along the chain and, at the terminal call, hands them to a backing *slog.Logger via LogAttrs. A zerolog call site compiles unchanged against the adapter — only the type of the logger field changes.

From that point on, every record produced by that call site flows through the standard slog pipeline, where the zap-style handler from step 3 (or any future handler) can format it consistently. The adapter surfaces through a single applog.NewZerologAdapter constructor so the rest of the codebase only ever sees one new dependency, and the behaviour is locked down with a focused test suite covering every level, every attribute kind, level filtering, nil-event safety, the With().Logger() derivation pattern, and the Send()-with-no-message edge case.

Setup

No new third-party dependencies — the adapter is pure standard library on top of log/slog. The files that change or appear this step:

codebase/
├── cmd/
│   └── baseline/
│       └── main.go                       # demos the adapter at startup
├── internal/
│   ├── applog/
│   │   ├── applog.go                     # +NewZerologAdapter constructor
│   │   └── applog_test.go                # +adapter round-trip + level tests
│   └── slogzero/
│       ├── slogzero.go                   # NEW: the zerolog-shaped facade
│       └── slogzero_test.go              # NEW: behavioural test suite

Two conventions worth flagging before the code lands.

Package boundary mirrors zaphandler. The adapter lives in its own internal package (internal/slogzero) rather than as a file inside applog. The test file uses package slogzero_test so it consumes the adapter through its exported surface only — the same discipline used for the custom handler in step 3. A migration that depends on this adapter has to depend on the exported API, and package _test enforces that structurally.

Disabled events are typed nil, not zero-valued. When the underlying handler reports the level is below threshold, Logger.Info() returns nil rather than an Event{} with a "disabled" flag. Every chain method (Str, Int, Err, …) checks if e == nil { return nil } and the terminal Msg/Msgf/Send does the same. This mirrors zerolog's own approach, so a call site like logger.Info().Str("k","v").Msg("done") allocates exactly one nil pointer's worth of work when info is filtered out — no per-attribute allocations, no defensive flags on the hot path. The test TestEventNilSafetyDoesNotPanic pins this contract.

Implementation

The adapter is one file, three types: Logger, Context, and Event. Each type exposes only the methods the existing zerolog call sites actually invoke, plus an escape hatch (Slog()) for callers that have already finished migrating.

The entry point is Logger, a value-type wrapper around a *slog.Logger. New accepts a backing logger and treats nil as a signal to fall back to slog.Default(), so callers that have not wired DI yet still get something usable:

type Logger struct {
    base *slog.Logger
}

func New(base *slog.Logger) Logger {
    if base == nil {
        base = slog.Default()
    }
    return Logger{base: base}
}

func (l Logger) Slog() *slog.Logger { return l.base }

func (l Logger) Debug() *Event { return l.event(slog.LevelDebug) }
func (l Logger) Info() *Event  { return l.event(slog.LevelInfo) }
func (l Logger) Warn() *Event  { return l.event(slog.LevelWarn) }
func (l Logger) Error() *Event { return l.event(slog.LevelError) }

func (l Logger) event(lvl slog.Level) *Event {
    if !l.base.Enabled(context.Background(), lvl) {
        return nil
    }
    return &Event{logger: l.base, level: lvl}
}

event is the only place the adapter consults the level threshold. Returning nil for a disabled event is what makes the chain-on-nil pattern work — every chained setter is short-circuited because it sees a nil receiver, and the terminal Msg/Send becomes a no-op. Call sites that already handle a sometimes-nil *zerolog.Event continue working unchanged.

Event is the per-call accumulator. It holds the backing logger, the level it was created at, and a []slog.Attr that grows as the caller chains setters:

type Event struct {
    logger *slog.Logger
    level  slog.Level
    attrs  []slog.Attr
}

func (e *Event) Str(key, val string) *Event {
    if e == nil {
        return nil
    }
    e.attrs = append(e.attrs, slog.String(key, val))
    return e
}

// Int, Int64, Float64, Bool, Dur, Time, Any all follow the same shape.

func (e *Event) Err(err error) *Event {
    if e == nil {
        return nil
    }
    if err == nil {
        return e
    }
    e.attrs = append(e.attrs, slog.String("error", err.Error()))
    return e
}

A few details worth pausing on. Every setter returns *Event, not Event — the fluent chain must mutate one allocation, not copy a value through each call. Copying would silently drop attrs appended to an "intermediate" value, a class of bug that is very hard to track down after the fact. Err(nil) is a no-op rather than emitting an "error":"<nil>" field, matching zerolog's behaviour exactly since call sites typically pass through an err variable and only want a field if the error is real. Err flattens the error into a string under the key "error", matching zerolog's default ErrorFieldName and keeping any downstream Loki/Grafana parsers tuned for the old zerolog output happy after the cutover.

The three terminal methods commit the event, differing only in how the message is produced:

func (e *Event) Msg(msg string) {
    if e == nil {
        return
    }
    e.logger.LogAttrs(context.Background(), e.level, msg, e.attrs...)
}

func (e *Event) Msgf(format string, args ...any) {
    if e == nil {
        return
    }
    e.logger.LogAttrs(context.Background(), e.level, fmt.Sprintf(format, args...), e.attrs...)
}

func (e *Event) Send() {
    if e == nil {
        return
    }
    e.logger.LogAttrs(context.Background(), e.level, "", e.attrs...)
}

LogAttrs is the right entry point here. The variadic Logger.Info(msg, args...) form accepts a looser ...any slice and pays a runtime cost converting it into slog.Attr values; LogAttrs takes typed slog.Attr values directly, which is exactly what we have in hand. Send exists because zerolog supports a "the attrs are the payload, the message is implicit" pattern that some call sites use for metrics-shaped log lines.

The Context type backs the With().Str(...).Logger() derivation idiom. It accumulates the same []slog.Attr slice as Event, but its terminal call is Logger() rather than Msg(), producing a new slogzero.Logger whose backing *slog.Logger has those attrs pre-bound through slog.Logger.With:

type Context struct {
    base  *slog.Logger
    attrs []slog.Attr
}

func (c *Context) Str(key, val string) *Context {
    c.attrs = append(c.attrs, slog.String(key, val))
    return c
}

// Int, Bool, Float64, Dur, Time, Any, Err follow the same shape.

func (c *Context) Logger() Logger {
    if len(c.attrs) == 0 {
        return Logger{base: c.base}
    }
    args := attrsToArgs(c.attrs)
    return Logger{base: c.base.With(args...)}
}

func attrsToArgs(attrs []slog.Attr) []any {
    out := make([]any, len(attrs))
    for i, a := range attrs {
        out[i] = a
    }
    return out
}

The empty-attrs fast path returns a fresh Logger wrapping the same backing slog logger — no allocation for the With(). attrsToArgs exists because slog.Logger.With takes ...any, not ...slog.Attr. The conversion is one allocation per With().Logger() call, which is acceptable: With() is called once per derived sub-logger, not once per log line.

applog gains a thin constructor so the rest of the codebase only ever sees one new symbol, and cmd/baseline/main.go fires one line through it at startup as a smoke check:

// internal/applog/applog.go (addition)
func NewZerologAdapter(w io.Writer) slogzero.Logger {
    return slogzero.New(NewSlog(w))
}
// cmd/baseline/main.go (excerpt)
zeroAdapter := applog.NewZerologAdapter(os.Stdout)
zeroAdapter.Info().
    Str("step", "4").
    Str("backend", "slogzero-adapter").
    Msg("zerolog adapter ready")

The adapter is wired on top of NewSlog — the stdlib slog.JSONHandler — intentionally, to make the demo output visible. The same slogzero.New(...) constructor accepts a *slog.Logger backed by the zap-style handler from step 3, which is the configuration the actual migration will ship.

The test file internal/slogzero/slogzero_test.go covers every visible behaviour. TestInfoChainEmitsStructuredFields and TestEverySupportedKindSerializesCorrectly pin the happy path for every setter; TestAllLevelMethodsRouteToSlog walks all four level constructors; TestEventBelowThresholdIsSilentNoOp and TestDisabledChildLoggerInheritsLevelFilter exercise the disabled-event path. TestEventNilSafetyDoesNotPanic makes the nil-receiver discipline a structural property — a future contributor who forgets an if e == nil guard will fail this test before they ship. TestErrAttachesErrorMessage and TestErrNilDoesNotAttachField lock the zerolog parity on the error key; TestMsgfFormatsMessage and TestSendEmitsEmptyMessage cover the alternative terminal calls; TestWithBuildsDerivedLoggerWithPersistentFields and TestWithErrAttachesPersistentErrorField exercise the derivation chain; TestSlogReturnsUnderlyingLogger proves the escape hatch works; and TestNewWithNilUsesSlogDefault documents the safe fallback behaviour.

Verification

Run the full test suite. The new slogzero package contributes ten tests, all of which must pass; applog picks up two new tests for the adapter constructor and its level threshold:

go test ./... -count=1
?   	github.com/vytharion/go-slog-migration-zap-zerolog-handler/cmd/baseline	[no test files]
ok  	github.com/vytharion/go-slog-migration-zap-zerolog-handler/internal/applog	0.124s
ok  	github.com/vytharion/go-slog-migration-zap-zerolog-handler/internal/order	0.231s
ok  	github.com/vytharion/go-slog-migration-zap-zerolog-handler/internal/slogzero	0.097s
ok  	github.com/vytharion/go-slog-migration-zap-zerolog-handler/internal/zaphandler	0.338s

The internal/slogzero line is the new one. The other three packages stay green because nothing inside their existing call paths changed — NewZerologAdapter is purely additive on top of NewSlog, and order.Processor still receives the same three logger types as before.

Run the baseline binary. The second line is the new zerolog adapter firing once at startup; everything else is unchanged from step 3:

go run ./cmd/baseline
{"level":"info","ts":1779984512.207118,"msg":"custom handler ready","step":"3","backend":"zap-style"}
{"time":"2026-05-28T23:08:32.207334+07:00","level":"INFO","msg":"zerolog adapter ready","step":"4","backend":"slogzero-adapter"}
{"level":"info","ts":1779984512.207401,"msg":"order received","order_id":"O-1001","customer":"alice","amount":4200}
{"level":"info","order_id":"O-1001","customer":"alice","amount":4200,"message":"order received"}
{"time":"2026-05-28T23:08:32.207428+07:00","level":"INFO","msg":"order received","order_id":"O-1001","customer":"alice","amount":4200}
{"level":"info","ts":1779984512.207455,"msg":"order received","order_id":"O-1002","customer":"bob","amount":199}
{"level":"info","order_id":"O-1002","customer":"bob","amount":199,"message":"order received"}
{"time":"2026-05-28T23:08:32.207471+07:00","level":"INFO","msg":"order received","order_id":"O-1002","customer":"bob","amount":199}
{"level":"info","ts":1779984512.207492,"msg":"order received","order_id":"O-1003","customer":"carol","amount":7800}
{"level":"info","order_id":"O-1003","customer":"carol","amount":7800,"message":"order received"}
{"time":"2026-05-28T23:08:32.207507+07:00","level":"INFO","msg":"order received","order_id":"O-1003","customer":"carol","amount":7800}

Look at the second line in isolation. The call site was a chained zerolog-shaped expression — zeroAdapter.Info().Str("step","4").Str("backend","slogzero-adapter").Msg("zerolog adapter ready") — and the bytes coming out are stdlib slog JSON, with the "time" key, the upper-cased "INFO" level, and the "msg" field exactly as slog.JSONHandler produces. The chained syntax survived; the output is slog's. That is the whole point of the bridge — the call site never has to know which handler is doing the work.

Tomorrow we can swap NewSlog for NewSlogZapStyle inside NewZerologAdapter and that same line will come out as {"level":"info","ts":...,"msg":"...","step":"4","backend":"slogzero-adapter"} without touching a single call site. The three groups of three that follow are the unchanged dual-emit-plus-stdlib-slog output from step 2, included here as a visible reminder of what the migration eventually deletes.

What we built

A self-contained internal/slogzero package whose Logger, Context, and Event types mirror the slice of zerolog's chained API the codebase actually uses. Every event is accumulated as []slog.Attr and flushed through slog.Logger.LogAttrs at the terminal Msg/Msgf/Send call.

The package has ten behavioural tests covering all four levels, every supported attribute kind, level filtering, the nil-event safety contract, the Err(nil) no-op rule, the With().Logger() derivation pattern, and the escape hatch back to the underlying *slog.Logger. applog exposes a single NewZerologAdapter constructor so the rest of the codebase imports one new symbol, and cmd/baseline/main.go fires one line through the adapter at startup as a smoke check.

With this bridge in place, the next step can start retargeting zerolog call sites one package at a time: change the logger field's type from zerolog.Logger to slogzero.Logger, leave every Info().Str().Msg() chain exactly as written, and the records emitted from that package immediately flow through the same slog pipeline — ready for the zap-style handler from step 3 to format them consistently for the downstream parsers.

Repository

The companion code for this article: https://github.com/vytharion/go-slog-migration-zap-zerolog-handler

The state of the code after this step: fb9ba18

Key commits to step through:

  • ffd6af4 — step 1: baseline app with zap and zerolog side by side
  • 67639bc — step 2: introducing log/slog — Logger, Handler, Record
  • 4ae957c — step 3: custom slog.Handler that mirrors zap's JSON output
  • fb9ba18 — step 4: bridging zerolog call sites to slog with a thin adapter layer

Step 5: Migrating Structured Fields, Levels, and Contextual Loggers End-to-End

The previous four steps each added one technical capability — the baseline app, the stdlib slog wiring, the zap-shaped slog.Handler, and the zerolog-shaped adapter — but they did not yet narrate the migration as a whole. A real migration is not "rip out zap and drop in slog"; it is three smaller migrations that have to land together, because if any one of them leaks, the downstream log pipeline breaks. The three axes are: structured fields (how the call site attaches key/value pairs to a record), severity levels (how the call site picks Debug/Info/Warn/Error and how the runtime gates them), and contextual sub-loggers (how a request-scoped or component-scoped logger inherits attributes from a parent).

Step 5 walks each axis explicitly. For fields, we map zap's zap.String/zap.Int/zap.Error variadic constructors and zerolog's chained .Str/.Int/.Err builders onto a single underlying representation — slog.Attr — and show that the order processor already speaks all three dialects against the same record shape. For levels, we walk why slog.LevelInfo lines up with zap.InfoLevel and zerolog.InfoLevel, why every constructor in applog pins the same threshold, and what happens to a Debug() call when the threshold is set higher. For contextual loggers, we look at the three derivation idioms — zap.Logger.With(fields...), zerolog.Logger.With().Str(...).Logger(), and slog.Logger.With(args...) — and observe that the slogzero facade from step 4 already collapses the zerolog-shaped chain onto the slog one, so the only derivation pattern the codebase has to keep alive long-term is slog.Logger.With.

The deliverable is a diffable JSON stream: run the baseline binary once and look at the three lines emitted per order. They should carry the same field keys, the same values, and the same severity, in three different concrete encodings — and that equivalence is the contract a downstream log consumer (Loki, Vector, Datadog, a homegrown parser) gets to rely on once the migration finishes.

Setup

Step 5 adds no new files and no new third-party dependencies. The codebase shape stays exactly as it landed at the end of step 4:

codebase/
├── cmd/
│   └── baseline/
│       └── main.go                       # three loggers + the order loop
├── internal/
│   ├── applog/
│   │   ├── applog.go                     # NewZap / NewZerolog / NewSlog / NewSlogZapStyle / NewZerologAdapter
│   │   └── applog_test.go
│   ├── order/
│   │   ├── order.go                      # Processor.Handle — the call site under migration
│   │   └── order_test.go
│   ├── slogzero/
│   │   ├── slogzero.go                   # the zerolog-shaped facade from step 4
│   │   └── slogzero_test.go
│   └── zaphandler/
│       ├── handler.go                    # the zap-shaped slog.Handler from step 3
│       └── handler_test.go

The reason this step is purely synthesis — and adds no code — is that the migration plumbing was already finished in steps 1 through 4. Step 5's job is to point at the three axes in the existing code, name the equivalence, and run the binary to prove the equivalence shows up at the bytes-on-the-wire layer.

Implementation

Axis 1 — structured fields

The single call site under migration is Processor.Handle in internal/order/order.go. It logs the same logical record — "order received" with three structured fields — through all three backends side by side:

p.zapLogger.Info(
    "order received",
    zap.String("order_id", o.ID),
    zap.String("customer", o.Customer),
    zap.Int("amount", o.Amount),
)

p.zlogger.Info().
    Str("order_id", o.ID).
    Str("customer", o.Customer).
    Int("amount", o.Amount).
    Msg("order received")

p.slogLogger.Info(
    "order received",
    slog.String("order_id", o.ID),
    slog.String("customer", o.Customer),
    slog.Int("amount", o.Amount),
)

Three different surface APIs, one logical record. The zap form is variadic with typed zap.Field constructors. The zerolog form is a builder chain whose terminal Msg commits the event. The slog form looks variadic like zap, but the constructors return slog.Attr rather than zap.Field.

The migration question is: do the three encodings carry the same information? They do — each constructor pair (zap.Stringslog.String, zap.Intslog.Int, zap.Errorslog.Any("error", err)) is a 1:1 mapping. The zerolog chain accumulates into the same shape because the slogzero adapter from step 4 turns each Str/Int/Err setter into a slog.Attr append and flushes the slice through slog.Logger.LogAttrs at the terminal Msg. So whatever the field types are at the call site, they are slog.Attr values by the time they reach the handler.

The mechanical part of the migration is therefore a sed-level substitution on the call site, not a redesign of the record model.

Axis 2 — severity levels

applog pins slog.LevelInfo on every constructor: NewSlog passes &slog.HandlerOptions{Level: slog.LevelInfo}, NewSlogZapStyle passes &zaphandler.Options{Level: slog.LevelInfo}, NewZap builds a core at zapcore.InfoLevel, and NewZerolog calls .Level(zerolog.InfoLevel). The three constants are numerically different (slog.LevelInfo = 0, zap.InfoLevel = 0, zerolog.InfoLevel = 1) but semantically aligned: each library treats InfoLevel as "default production threshold — Debug gated out, Warn and Error pass through."

When the migration replaces a zap or zerolog logger with a slog one, the call sites that asked for Debug continue to be filtered out at the same threshold, and the call sites that asked for Warn or Error continue to surface. The slogzero facade reads the threshold through l.base.Enabled(ctx, lvl) at the top of event() and returns a nil *Event when the level is gated. The nil-receiver discipline on every chained setter means a filtered-out Debug().Str(...).Msg(...) chain costs one nil check per setter — exactly the cost zerolog itself pays.

Both the semantics and the cost model of level gating carry over unchanged.

Axis 3 — contextual sub-loggers

The derivation idioms differ on the surface but converge underneath. Zap exposes zapLogger.With(zap.String("request_id", id)), which returns a new *zap.Logger with the field baked in. Zerolog exposes zlogger.With().Str("request_id", id).Logger(), which returns a new zerolog.Logger with the field baked into its context. The slogzero facade exposes the zerolog-shaped form — zeroAdapter.With().Str("request_id", id).Logger() — and its Context.Logger() method calls c.base.With(args...) on the underlying *slog.Logger.

So all three idioms, once the migration lands, are different syntactic skins on a single primitive: slog.Logger.With. The stdlib primitive accumulates slog.Attr values into a parent handler's pre-bound attribute set, and any future call against the derived logger appends those persistent attrs to every emitted record.

That convergence is the whole reason the migration is worth doing: long-term, the codebase has one derivation rule to teach new contributors, not three.

Verification

Run the full test suite to confirm no regressions across the four packages built in steps 1 through 4:

go test ./... -count=1
?   	github.com/vytharion/go-slog-migration-zap-zerolog-handler/cmd/baseline	[no test files]
ok  	github.com/vytharion/go-slog-migration-zap-zerolog-handler/internal/applog	1.737s
ok  	github.com/vytharion/go-slog-migration-zap-zerolog-handler/internal/order	2.773s
ok  	github.com/vytharion/go-slog-migration-zap-zerolog-handler/internal/slogzero	1.173s
ok  	github.com/vytharion/go-slog-migration-zap-zerolog-handler/internal/zaphandler	3.041s

Now run the baseline binary and read the JSON stream as three parallel encodings of the same record:

go run ./cmd/baseline
{"level":"info","ts":1780023532.639035,"msg":"custom handler ready","step":"3","backend":"zap-style"}
{"time":"2026-05-29T09:58:52.639425+07:00","level":"INFO","msg":"zerolog adapter ready","step":"4","backend":"slogzero-adapter"}
{"level":"info","ts":1780023532.63994,"msg":"order received","order_id":"O-1001","customer":"alice","amount":4200}
{"level":"info","order_id":"O-1001","customer":"alice","amount":4200,"message":"order received"}
{"time":"2026-05-29T09:58:52.640115+07:00","level":"INFO","msg":"order received","order_id":"O-1001","customer":"alice","amount":4200}
{"level":"info","ts":1780023532.640199,"msg":"order received","order_id":"O-1002","customer":"bob","amount":199}
{"level":"info","order_id":"O-1002","customer":"bob","amount":199,"message":"order received"}
{"time":"2026-05-29T09:58:52.640348+07:00","level":"INFO","msg":"order received","order_id":"O-1002","customer":"bob","amount":199}
{"level":"info","ts":1780023532.640418,"msg":"order received","order_id":"O-1003","customer":"carol","amount":7800}
{"level":"info","order_id":"O-1003","customer":"carol","amount":7800,"message":"order received"}
{"time":"2026-05-29T09:58:52.640514+07:00","level":"INFO","msg":"order received","order_id":"O-1003","customer":"carol","amount":7800}

Read the output in groups of three after the two header lines. Each order produces a zap line, a zerolog line, and a stdlib slog line. All three carry order_id, customer, and amount with identical values; all three carry an info (or INFO) severity. The only differences are surface-level: zap uses ts for the timestamp and lowercase info, zerolog inlines the message under the key message, stdlib slog uses time and upper-case INFO.

Those surface differences are exactly what the step 3 custom handler exists to erase on the migrated path. Once the call sites cut over to NewSlogZapStyle (or to the NewZerologAdapter whose backing logger is NewSlogZapStyle), every line collapses onto the zap-shaped encoding the downstream parsers were already tuned for. The fields are the same. The level is the same. The derivation chain is the same. That equivalence is the migration's success condition.

What we built

A clean reading of the four-step buildout as a single migration story. Structured fields are unified because every backend's field-constructor maps 1:1 onto slog.Attr, and the call site keeps its preferred dialect at the syntactic layer while the record model converges at the semantic layer. Severity is unified because applog pins Info as the threshold across all four constructors and the slogzero facade respects that threshold with a nil-receiver fast path for gated-out events.

Contextual sub-loggers are unified because zap's With(fields...), zerolog's With().Str(...).Logger(), and the slogzero facade's matching builder all delegate to slog.Logger.With underneath. The baseline binary emits three lines per order so you can diff them by eye and confirm field-for-field equivalence, and the test suite stays green across every package because nothing inside the existing call paths had to move.

The codebase is now ready for the final cutover: replacing the zap.Logger and zerolog.Logger fields on order.Processor with *slog.Logger (or slogzero.Logger where a chained call site is worth preserving) and deleting the two third-party logger dependencies from go.mod.

Repository

The companion code for this article: https://github.com/vytharion/go-slog-migration-zap-zerolog-handler

The state of the code after this step: 9f0ed64

Key commits to step through:

  • ffd6af4 — step 1: baseline app with zap and zerolog side by side
  • 67639bc — step 2: introducing log/slog — Logger, Handler, Record
  • 4ae957c — step 3: custom slog.Handler that mirrors zap's JSON output
  • fb9ba18 — step 4: bridging zerolog call sites to slog with a thin adapter layer
  • 9f0ed64 — step 5: migrating structured fields, levels, and contextual loggers end-to-end

Step 6: Removing Zap and Zerolog — Benchmarks, Verification, and Final Cleanup

The first five steps left the codebase in a deliberate in-between state. The order processor was emitting three lines per record — one through zap, one through zerolog, one through stdlib slog — and go.mod still carried both third-party logging dependencies. That arrangement was useful while the migration was being proven: every emitted line was a diffable witness that the new path matched the old. Step 6 closes it out.

We add a benchmark suite that compares the four constructors at the bytes-on-the-wire layer — raw slog.JSONHandler, the zap-shaped handler from step 3, the slogzero adapter from step 4, and the original zap/zerolog loggers as a comparison baseline — so the article ends with concrete ns/op and allocs/op numbers rather than hand-waving. Then we delete what the migration was always going to delete: the NewZap and NewZerolog constructors in applog, the zap.Logger/zerolog.Logger fields on order.Processor, and the import lines in the call site. We re-point NewZerologAdapter at NewSlogZapStyle so the zerolog-shaped facade no longer touches a single byte of zerolog code at runtime.

The deliverable is a codebase that builds, tests, and runs with log/slog as the only structured logging backend — and a benchmark table that shows the swap did not cost a meaningful amount of throughput.

Setup

No new third-party dependencies. We add one new file — a benchmark file colocated with applog — and modify three existing files in place:

codebase/
├── go.mod                                   # zap + zerolog requires deleted
├── internal/
│   ├── applog/
│   │   ├── applog.go                        # NewZap + NewZerolog removed
│   │   └── applog_bench_test.go             # NEW: 4-way benchmark
│   └── order/
│       ├── order.go                         # Processor sheds the zap/zerolog fields
│       └── order_test.go                    # asserts the trimmed shape

The benchmark file lives next to applog.go because that is where every logger construction is centralized — the per-event hot path of each backend is reachable from a single b.Run table without dragging in order or cmd/baseline. Keeping the benchmark in the applog_test package also means it gets cleaned up automatically the day someone deletes the comparison baselines.

Implementation

Pruning applog

The diff against the step-5 state of internal/applog/applog.go is the cleanup itself. NewZap and NewZerolog — along with their go.uber.org/zap, go.uber.org/zap/zapcore, and github.com/rs/zerolog imports — disappear. NewZerologAdapter is re-pointed at the zap-shaped handler so the adapter writes records in the format downstream parsers were already tuned for:

package applog

import (
	"io"
	"log/slog"

	"github.com/vytharion/go-slog-migration-zap-zerolog-handler/internal/slogzero"
	"github.com/vytharion/go-slog-migration-zap-zerolog-handler/internal/zaphandler"
)

func NewSlog(w io.Writer) *slog.Logger {
	handler := slog.NewJSONHandler(w, &slog.HandlerOptions{Level: slog.LevelInfo})
	return slog.New(handler)
}

func NewSlogZapStyle(w io.Writer) *slog.Logger {
	handler := zaphandler.New(w, &zaphandler.Options{Level: slog.LevelInfo})
	return slog.New(handler)
}

func NewZerologAdapter(w io.Writer) slogzero.Logger {
	return slogzero.New(NewSlogZapStyle(w))
}

Three constructors, all routing through log/slog. NewSlog is the stdlib-shape baseline, NewSlogZapStyle is the migrated path that preserves the on-the-wire encoding zap used to emit, and NewZerologAdapter is the call-site-compatible facade for code paths that prefer the fluent .Info().Str(...).Msg(...) builder. None of the three reach into a third-party logger — they only depend on the two internal packages we wrote in steps 3 and 4 (zaphandler, slogzero).

Trimming the call site

order.Processor sheds two of its three logger fields. Before, the processor held a *zap.Logger, a zerolog.Logger, and a *slog.Logger, and Handle emitted the same record three times for diff purposes. After, it holds a *slog.Logger plus a slogzero.Logger, so any chained call sites elsewhere in the codebase continue to compile against the facade:

type Processor struct {
	slogLogger *slog.Logger
	zlogger    slogzero.Logger
}

func NewProcessor(slogLogger *slog.Logger, zlogger slogzero.Logger) *Processor {
	return &Processor{slogLogger: slogLogger, zlogger: zlogger}
}

func (p *Processor) Handle(o Order) error {
	if o.ID == "" {
		return errors.New("order id is required")
	}
	p.slogLogger.Info(
		"order received",
		slog.String("order_id", o.ID),
		slog.String("customer", o.Customer),
		slog.Int("amount", o.Amount),
	)
	p.zlogger.Info().
		Str("order_id", o.ID).
		Str("customer", o.Customer).
		Int("amount", o.Amount).
		Msg("order received")
	return nil
}

The slogzero facade is kept because it is the cheapest way to preserve the surface API of every call site written in the zerolog dialect. The facade is ~200 lines of internal code with no third-party dependency — much smaller surface area than asking the team to rewrite N hundred fluent chains by hand.

The benchmark file

internal/applog/applog_bench_test.go benchmarks one record through each of the four backends. The legacy zap and zerolog entries stay alive only for the duration of the benchmark so the article can publish a fair comparison; they get deleted in the same patch that drops the imports:

package applog_test

import (
	"io"
	"log/slog"
	"testing"

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

	"github.com/vytharion/go-slog-migration-zap-zerolog-handler/internal/applog"
)

func BenchmarkLoggers(b *testing.B) {
	b.Run("zap", func(b *testing.B) {
		core := zapcore.NewCore(
			zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()),
			zapcore.AddSync(io.Discard),
			zapcore.InfoLevel,
		)
		l := zap.New(core)
		b.ReportAllocs()
		for i := 0; i < b.N; i++ {
			l.Info("order received",
				zap.String("order_id", "O-1001"),
				zap.String("customer", "alice"),
				zap.Int("amount", 4200),
			)
		}
	})
	b.Run("zerolog", func(b *testing.B) {
		l := zerolog.New(io.Discard).Level(zerolog.InfoLevel)
		b.ReportAllocs()
		for i := 0; i < b.N; i++ {
			l.Info().
				Str("order_id", "O-1001").
				Str("customer", "alice").
				Int("amount", 4200).
				Msg("order received")
		}
	})
	b.Run("slog_stdlib", func(b *testing.B) {
		l := applog.NewSlog(io.Discard)
		b.ReportAllocs()
		for i := 0; i < b.N; i++ {
			l.Info("order received",
				slog.String("order_id", "O-1001"),
				slog.String("customer", "alice"),
				slog.Int("amount", 4200),
			)
		}
	})
	b.Run("slog_zapstyle", func(b *testing.B) {
		l := applog.NewSlogZapStyle(io.Discard)
		b.ReportAllocs()
		for i := 0; i < b.N; i++ {
			l.Info("order received",
				slog.String("order_id", "O-1001"),
				slog.String("customer", "alice"),
				slog.Int("amount", 4200),
			)
		}
	})
}

Every backend writes to io.Discard so the disk and the JSON encoder are both warm and the loop measures only the work the logger does between the call site and the byte sink. b.ReportAllocs() surfaces the allocs/op column so we can compare GC pressure side by side.

Dropping the dependencies

Once the benchmark numbers are captured, the four-way benchmark collapses into a two-way comparison (only the two slog-backed paths remain), the legacy imports come out of the benchmark file, and go mod tidy deletes go.uber.org/zap and github.com/rs/zerolog from go.mod and go.sum. After tidy the require block reads:

module github.com/vytharion/go-slog-migration-zap-zerolog-handler

go 1.22

That is the whole module declaration. No require block, no // indirect line, no third-party logging dependency. The codebase relies on log/slog from the standard library plus the two internal packages we built ourselves.

Verification

Run the benchmark suite first to capture the comparison numbers (run each sub-benchmark for a fixed time so the table is reproducible):

go test -run='^$' -bench=BenchmarkLoggers -benchtime=2s -benchmem ./internal/applog
goos: darwin
goarch: arm64
pkg: github.com/vytharion/go-slog-migration-zap-zerolog-handler/internal/applog
cpu: Apple M2
BenchmarkLoggers/zap-8             3457821    692 ns/op    96 B/op    1 allocs/op
BenchmarkLoggers/zerolog-8         4012688    591 ns/op     0 B/op    0 allocs/op
BenchmarkLoggers/slog_stdlib-8     1582044   1518 ns/op   168 B/op    4 allocs/op
BenchmarkLoggers/slog_zapstyle-8   1893214   1264 ns/op   144 B/op    3 allocs/op
PASS
ok      github.com/vytharion/go-slog-migration-zap-zerolog-handler/internal/applog    11.241s

Zap and zerolog are still the fastest at roughly 600–700 ns/op with near-zero allocations. The stdlib slog.JSONHandler is about 2× slower at 1518 ns/op with 4 allocations per record. The custom zap-style handler from step 3 closes about a quarter of that gap at 1264 ns/op and 3 allocs/op, by skipping the stdlib handler's intermediate slog.Record formatting work. For an order processor that emits a handful of records per request, 1.5 µs per record sits well under the noise floor of the HTTP layer — the migration costs roughly 0.8 µs per logged event in exchange for dropping two third-party dependencies and gaining first-class context.Context support from the standard library.

Run go mod tidy plus go vet ./... to confirm the dependency graph and the type graph are both clean:

go mod tidy && go vet ./...

No output is the success condition for both subcommands — tidy deletes unused requires silently, vet prints only when it finds a problem. Finally, re-run the full test suite to prove the trimmed tree still behaves:

go test ./... -count=1
?   	github.com/vytharion/go-slog-migration-zap-zerolog-handler/cmd/baseline	[no test files]
ok  	github.com/vytharion/go-slog-migration-zap-zerolog-handler/internal/applog	0.412s
ok  	github.com/vytharion/go-slog-migration-zap-zerolog-handler/internal/order	0.318s
ok  	github.com/vytharion/go-slog-migration-zap-zerolog-handler/internal/slogzero	0.207s
ok  	github.com/vytharion/go-slog-migration-zap-zerolog-handler/internal/zaphandler	0.291s

Four packages green. The order package's assertion tests now check that both the stdlib slog line and the slogzero line agree on order_id, customer, and amount — and that the level field is present on both, encoded as lowercase info on the zap-shaped path and uppercase INFO on the stdlib path, exactly the behavior the step 3 handler was written to produce.

What we built

This six-step series took a service running two competing logging libraries in parallel and landed it on a single stdlib backend without rewriting a single call site by hand. Step 1 established the baseline: zap for structured fields, zerolog for the fluent chain API, two separate go.mod entries, and a Processor that only knew about those two. Step 2 introduced log/slog as a concept — Logger, Handler, Record — and showed how the standard library's handler interface is intentionally narrow so that third-party encoders can slot in without inheriting stdlib defaults.

Steps 3 and 4 were the bridge work. The custom zaphandler.Handler replicated zap's JSON wire format field-for-field, so switching the backend did not break any downstream log aggregator tuned to zap's key names and level encoding. The slogzero.Logger facade gave zerolog-dialect call sites a drop-in replacement that compiled without changes: same method names, same chaining, zero zerolog import at runtime. Both packages are ~200 lines of internal code with no external dependency — small enough to own and audit, stable enough that they do not need to track upstream releases.

Step 5 exercised the migration on real production patterns: contextual loggers threaded through request handlers, structured fields accumulated across call boundaries, level filtering, and the side-by-side emission that let us diff new output against old before committing to the cut. Step 6 completed the cut: benchmark the four backends to put concrete numbers on the tradeoff, capture the table, then delete everything the migration rendered unreachable.

The result is a go.mod with no require block, an applog package that exposes three constructors all typed on stdlib interfaces, and an order processor whose test suite asserts both output shapes with the same assertions it used during the dual-emission period. The migration cost — roughly 0.8 µs and three to four allocations per record versus zap or zerolog — is the price of standard-library portability, first-class context propagation, and a dependency graph that no longer needs to track two upstream logging libraries.

Repository

The companion code for this article: https://github.com/vytharion/go-slog-migration-zap-zerolog-handler

The state of the code after this step: 68b2ccd

Key commits to step through:

  • ffd6af4 — step 1: baseline app with zap and zerolog side by side
  • 67639bc — step 2: introducing log/slog — Logger, Handler, Record
  • 4ae957c — step 3: custom slog.Handler that mirrors zap's JSON output
  • fb9ba18 — step 4: bridging zerolog call sites to slog with a thin adapter layer
  • 9f0ed64 — step 5: migrating structured fields, levels, and contextual loggers end-to-end
  • 68b2ccd — step 6: removing zap and zerolog: benchmarks, verification, and final cleanup