The before state
A typical mature Go codebase:
log(stdlib, unstructured) for early code.logrusfor the first structured-logging push.zapfor “we need faster logging.”zerologfor “we need even faster logging.”- A team-local wrapper around one of the above.
Five logging implementations; five configuration surfaces; new engineers picking the wrong one.
What slog gives you
Standardised structured logging in the stdlib:
import "log/slog"
// Set up once
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
// Use everywhere
slog.Info("user logged in", "user_id", id, "method", "passkey")
slog.Error("payment failed", "tx_id", tx, "amount", amt, "error", err)
Output:
{"time":"2026-05-26T14:23:01Z","level":"INFO","msg":"user logged in","user_id":"alice","method":"passkey"}
JSON or text handler. Levels. Structured key/value attributes. Context-aware (slog.InfoContext(ctx, ...) includes trace IDs automatically if you wire it up).
The migration
For Genie, the migration was a regex sweep + a few manual fixes:
# logrus.Info("msg", "key", val) → slog.Info("msg", "key", val)
# log.Printf("user %s logged in", uid) → slog.Info("user logged in", "user_id", uid)
# zap.S().Errorw("...", "key", val) → slog.Error("...", "key", val)
Two patterns took manual work:
- Pre-formatted log strings. Old
log.Printf("got %d items for user %s", n, uid)becomesslog.Info("got items", "count", n, "user_id", uid). The strings change shape. - Custom log levels.
logrus.Tracedoesn’t map to slog (no Trace level). Either drop it or define a custom level.
Two days for the codebase; ~700 log statements migrated. New PRs use slog naturally; the old libraries got removed in a follow-up PR with no replacements.
What you get back
Fewer dependencies. Five logging libraries gone. go.sum shrinks.
Consistent log shape. Every service emits the same JSON structure. Log aggregators (Loki, Elasticsearch, Datadog) parse uniformly.
slog.Group for nested attributes. When a structured field has sub-fields:
slog.Info("agent dispatched",
slog.Group("agent",
"id", agentID,
"tier", tier,
"risk", riskClass,
),
"trace_id", traceID,
)
Handler composition. Multiple handlers can chain. A handler that adds the trace ID + a handler that filters by package + a handler that ships to stdout. Each is small; composition is clean.
What slog doesn’t replace
- OpenTelemetry traces. Slog is for logs; OTel is for spans. Different shapes; complementary.
- High-volume metric counters. Logging every request to count it is wasteful. Prometheus counters are the right tool.
For logs specifically — the stuff you grep for in production — slog is the right tool now. The case for any third-party logging library in a new Go project is weak. The case for migrating an existing project is one good afternoon.