log/slog shipped in Go 1.21 in August 2023. It’s now been in the standard library for two years, and most production Go services I encounter are still on zap or zerolog. That’s not inertia — it’s a reasonable position. The third-party loggers are battle-tested and fast, and “the standard library now has one too” is not by itself a reason to migrate a working system.

We migrated one of our services anyway. This post is about why, what the migration actually involved, and the honest accounting of what we gained and what we gave up.

Why Migrate At All

The case for slog is not performance. It’s about being the lingua franca. Before slog, every library that wanted to log had a problem: it couldn’t depend on your logger, so it either invented its own interface, used the anaemic standard log package, or forced a dependency on zap/logrus. The ecosystem fragmented into incompatible logging interfaces.

slog fixes this at the type-system level. *slog.Logger and the slog.Handler interface are now the standard seam. A library can take a *slog.Logger (or write to the default one) and your application controls where it goes, in what format, at what level — without the library knowing anything about your logging backend.

Our concrete motivation: we had three services and two of them used zap while one had drifted to zerolog, and a couple of internal libraries had their own logger interfaces with adapters for each. The adapter layer was the smell. slog lets the libraries target the standard interface and the applications choose the backend. That’s the real win — interface standardisation, not features.

The Architecture That Makes Migration Painless

The single most useful thing to understand: slog separates the frontend (slog.Logger, the API you call) from the backend (slog.Handler, which formats and writes). This separation is what makes an incremental migration possible.

1
2
3
4
5
6
7
8
// Frontend: what your code calls
logger := slog.New(handler)
logger.Info("order placed", "order_id", id, "amount", amt)

// Backend: where it goes and how it's formatted
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelInfo,
})

The crucial consequence: you can keep zap as the backend while migrating your call sites to the slog frontend. The zap maintainers ship zapslog, a slog.Handler backed by a zap core:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import (
    "log/slog"
    "go.uber.org/zap"
    "go.uber.org/zap/exp/zapslog"
)

zapLogger, _ := zap.NewProduction()
handler := zapslog.NewHandler(zapLogger.Core(), nil)
logger := slog.New(handler)

// This slog call is formatted and written by zap underneath:
logger.Info("server started", "port", 8080)

This is the key to not doing a big-bang rewrite. You flip the backend once — every log line still goes through zap, same format, same performance, same output your dashboards and alerts already parse — and then you migrate call sites from the zap API to the slog API at your own pace. Nothing observable changes for operations until you decide to swap the backend to slog.JSONHandler at the end.

The Migration In Phases

How we actually sequenced it:

Phase 0 — establish the seam. Create one place that builds the logger and inject it everywhere. If your service was already passing a *zap.Logger around (rather than reaching for a global), this is done. If it used zap.L() globals everywhere, fix that first — it’s the actual hard part, and it’s a pre-existing problem, not a slog problem.

Phase 1 — swap the backend, keep zap underneath. Replace the logger construction with slog.New(zapslog.NewHandler(...)). Change the injected type from *zap.Logger to *slog.Logger. The compiler now flags every call site that uses the zap API. Output is byte-for-byte what it was.

Phase 2 — migrate call sites. Go through the compiler errors and rewrite each call to the slog API. This is mechanical (more on the gotchas below). Do it in as many PRs as you like — the service compiles and ships at every step.

Phase 3 — swap the backend to stdlib (optional). Once no call site uses zap directly, you can replace zapslog with slog.NewJSONHandler and drop the zap dependency. We did this; you might not, if you depend on zap features the stdlib handler lacks.

The Call-Site Gotchas

The API translation is mostly mechanical but has sharp edges that bit us.

Structured fields go from typed constructors to loose key-value pairs. zap’s signature is typed; slog’s default is ...any alternating keys and values:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// zap — typed, compiler-checked field constructors
logger.Info("order placed",
    zap.String("order_id", id),
    zap.Int64("amount", amt),
)

// slog — loose key/value pairs, NOT compiler-checked
logger.Info("order placed",
    "order_id", id,
    "amount", amt,
)

This is the biggest regression, and it’s a real one: the loose form is not type-checked. An odd number of arguments, or a non-string where a key belongs, is a runtime problem, not a compile error. slog handles it gracefully (it doesn’t panic — it logs a !BADKEY entry), but a typo’d log line silently produces malformed output.

The mitigation: use the typed slog.Attr form in code you care about, and turn on the vet check. go vet has an slog analyzer that catches mismatched key/value pairs and a few other mistakes. Make sure it’s in CI.

1
2
3
4
5
// Typed, allocation-friendly, vet-friendly form:
logger.LogAttrs(ctx, slog.LevelInfo, "order placed",
    slog.String("order_id", id),
    slog.Int64("amount", amt),
)

LogAttrs is also the path to lower allocations — it avoids the ...any boxing. In hot paths, prefer it.

Context becomes first-class. This is a genuine improvement over zap. slog’s InfoContext/LogAttrs take a context.Context, and a custom handler can pull values out of it — request IDs, trace IDs, tenant — without you threading them through every call:

1
2
3
4
5
6
func (h *traceHandler) Handle(ctx context.Context, r slog.Record) error {
    if span := trace.SpanContextFromContext(ctx); span.HasTraceID() {
        r.AddAttrs(slog.String("trace_id", span.TraceID().String()))
    }
    return h.next.Handle(ctx, r)
}

We had this in zap via a wrapper, but it was bespoke. In slog it’s the idiomatic pattern, which means it composes with other handlers.

With and WithGroup behave the same; named loggers don’t translate directly. logger.With("component", "ingest") returns a child logger with that attribute attached — same as zap’s .With(...). zap’s named-logger concept (.Named("ingest")) doesn’t have a direct slog equivalent; we modelled it as a component attribute, which is arguably cleaner anyway.

The Honest Performance Accounting

This is where you have to be clear-eyed. zap’s entire reason for existing is allocation-free structured logging on the hot path, and it is still better at that than slog.

  • For the vast majority of services, logging is not the hot path and the difference is irrelevant. slog with the JSON handler is plenty fast. If you log on request boundaries and errors, you will never notice.
  • For genuinely log-heavy hot paths — high-frequency systems, anything logging in a tight loop — zap with its typed, pooled, zero-allocation core still wins measurably. slog.LogAttrs narrows the gap but doesn’t close it, and the default ...any API is worse.
  • You can have it both ways during and after migration: slog frontend, zap backend via zapslog. You keep zap’s write-path performance and the standard frontend. We kept this configuration for the service that had a chattier hot path rather than going to the stdlib JSON handler.

The rule I’d give: migrate the frontend everywhere for the ecosystem benefit; choose the backend per service based on whether logging is actually on your hot path.

What I’d Tell Someone Starting Today

  • New service? Use slog from day one. The interface-standardisation benefit is free and the performance is fine for normal service logging.
  • Existing service on zap that works? There’s no urgency. Migrate when the multi-logger fragmentation actually costs you something — when you’re writing adapter layers, or a dependency forces a logger interface mismatch. That’s the trigger, not “slog exists.”
  • When you do migrate: swap the backend first with zapslog, migrate call sites incrementally, turn on go vet’s slog analyzer in CI on day one. The vet check is non-negotiable — it buys back the type safety you lose moving off zap’s typed constructors.

The slog migration is less dramatic than the changelog makes it sound. The feature you’re adopting isn’t a faster or fancier logger — it’s a standard one, and the value is entirely in being the seam the whole ecosystem can target. Move the frontend for that benefit, keep zap as the backend wherever logging is hot, and lean on go vet to replace the compile-time safety you trade away. Done incrementally, it’s a series of boring, shippable PRs rather than a rewrite — which is exactly what you want from an infrastructure change.