For years, logging in Go meant one of two things: log.Printf with a format string nobody could parse later, or pulling in zap, logrus, or zerolog and arguing about which one. I've done both. The format strings turn into grep archaeology at 2am, and the third-party logger is one more dependency to keep current.
Go 1.21 ended that argument. It shipped log/slog, structured logging in the standard library. You get JSON output, typed fields, log levels, and pluggable handlers without adding a single dependency. This guide is the full setup: handlers, attributes, groups, swapping out your old log calls, request-scoped fields, secret redaction, and keeping it fast. Every example is plain stdlib Go.
What is structured logging, and why does Go need log/slog?
Structured logging records each event as a set of key-value pairs instead of a flat string, so machines can filter, group, and alert on your logs. Go 1.21 added log/slog to do exactly this in the standard library.
Here's the difference in one line. The old way bakes your data into a sentence:
log.Printf("user %s placed order %s for $%.2f", userID, orderID, amount)
// user cus_456 placed order ord_123 for $42.00That reads fine to a human and is useless to a machine. You can't filter for "all orders over $100" or "every event for cus_456" without writing fragile regexes. Structured logging records the same event as fields:
slog.Info("order placed", "user", userID, "order", orderID, "amount", amount)
// {"time":"...","level":"INFO","msg":"order placed","user":"cus_456","order":"ord_123","amount":42}Now your log aggregator (Loki, Elasticsearch, Datadog, CloudWatch) indexes those fields and you query them like a database. Before slog you reached for a library to get this. Since 1.21 it's built in, and the API is small enough to learn in an afternoon.
How do you create a slog logger with a JSON handler?
You wrap an io.Writer in a handler and pass it to slog.New. The handler decides the output format, and slog ships two: JSONHandler and TextHandler.
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("server started", "port", 8080, "env", "prod")
// {"time":"2026-10-21T09:00:00.000Z","level":"INFO","msg":"server started","port":8080,"env":"prod"}Swap the handler and the same call produces human-readable text instead:
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
logger.Info("server started", "port", 8080, "env", "prod")
// time=2026-10-21T09:00:00.000Z level=INFO msg="server started" port=8080 env=prodThe pattern I use: TextHandler to os.Stdout in local dev because it's easy to read, and JSONHandler in production because your log pipeline ingests it. Pick the handler from an environment variable at startup and the rest of your code never knows the difference.
The second argument is *slog.HandlerOptions, which is where levels, source info, and attribute rewriting live. Passing nil accepts the defaults: level Info and up, no source location.
One thing worth knowing up front: a *slog.Logger and its handler are safe for concurrent use. You build one at startup and share it across every goroutine, no mutex required. There's no reason to create a fresh logger per request except to attach fields, and that's exactly what With is for, which we get to below.
How do you add structured attributes to a log line?
You pass key-value pairs after the message, or use typed helpers like slog.String and slog.Int for speed and safety. Related fields nest under one key with slog.Group.
The loose form takes alternating keys and values:
logger.Info("payment captured", "amount", 42, "currency", "USD")It's convenient, but a stray argument logs a !BADKEY entry instead of failing loudly, and every value gets boxed into an any. The typed form avoids both problems and is what I default to in real services:
logger.Info("order placed",
slog.String("order_id", "ord_123"),
slog.Int("items", 3),
slog.Group("customer",
slog.String("id", "cus_456"),
slog.String("tier", "gold"),
),
)
// {"...","msg":"order placed","order_id":"ord_123","items":3,"customer":{"id":"cus_456","tier":"gold"}}The Group keeps your customer fields together as a nested object in JSON, which makes downstream queries cleaner. Type helpers exist for every common type (slog.Bool, slog.Time, slog.Duration, slog.Any for the rest), so you rarely fall back to the loose form.
How do you replace your existing log calls with slog?
You call slog.SetDefault with your logger. That powers the top-level slog.Info and slog.Error helpers, and it also routes the standard log package through slog, so old log.Print calls become structured without you touching them.
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
slog.Info("now structured") // top-level helper, no logger variable to pass around
log.Print("legacy call, also routed") // the old log package flows through slog tooThat second line matters more than it looks. In a real codebase you've got log.Println calls scattered across packages, plus libraries that log through the standard log package. SetDefault captures all of them at once and re-emits them as structured records. You migrate gradually, converting hot paths to explicit slog calls when you get to them, while everything else still lands in the same structured stream.
For libraries, avoid SetDefault and pass an explicit *slog.Logger instead. Global state is fine for an application you own and a trap for code other people import.
How do you attach request-scoped fields like a request ID?
You build a child logger with slog.With that carries the shared fields, then use it for the rest of the request. Every line it emits includes those fields automatically, so you set them once.
func handleOrder(w http.ResponseWriter, r *http.Request) {
reqLog := slog.With(
slog.String("request_id", r.Header.Get("X-Request-ID")),
slog.String("path", r.URL.Path),
)
reqLog.InfoContext(r.Context(), "request received")
// ... do work ...
reqLog.InfoContext(r.Context(), "order created", slog.String("order_id", id))
}Both lines carry request_id and path with no repetition, which is what makes a request traceable across a noisy log stream. With returns a new logger and doesn't mutate the original, so it's safe to derive one per request.
The InfoContext variant passes the request's context.Context down to the handler. On its own the built-in handlers ignore the context, but it's the hook a custom or third-party handler uses to pull trace IDs or deadlines out of the request. Wire the logger into your context once and every layer below logs with the same fields.
How do you redact secrets and reshape output with ReplaceAttr?
You set ReplaceAttr in slog.HandlerOptions. It runs on every attribute before the line is written, so you can redact secrets, rename keys, or reformat timestamps in one place.
opts := &slog.HandlerOptions{
Level: slog.LevelDebug,
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
switch a.Key {
case "password", "token", "api_key":
return slog.String(a.Key, "REDACTED")
case slog.TimeKey:
return slog.String("ts", a.Value.Time().Format(time.RFC3339))
}
return a
},
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))This is the feature I wish every logger had. One function, applied centrally, stops secrets from ever reaching disk, no matter which call site logged them. Return slog.Attr{} (the empty attribute) to drop a field entirely, or rewrite its key to standardize names across services. The same secret-scrubbing instinct applies on the JVM, where JDK 27 added JFR event redaction for the same reason: secrets leak through observability tooling unless you scrub them at the source.
How do you control log levels and keep slog fast?
You set a minimum level in HandlerOptions, use slog.LevelVar to change it at runtime, and reach for slog.LogAttrs on hot paths to skip extra allocations.
A static level is one field:
opts := &slog.HandlerOptions{Level: slog.LevelWarn} // drop Debug and InfoBut a fixed level means redeploying to turn on debug logging during an incident, which is the worst possible time. LevelVar makes it dynamic, so you can flip it from a signal handler or an admin endpoint:
levelVar := new(slog.LevelVar) // starts at Info
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: levelVar}))
levelVar.Set(slog.LevelDebug) // now debug logs flow, no restartOn a genuinely hot path, the variadic ...any form costs an allocation per call to box the arguments. LogAttrs takes typed attributes directly and is the allocation-light path slog was designed around:
slog.LogAttrs(ctx, slog.LevelInfo, "cache miss",
slog.String("key", key),
slog.Int("ttl_seconds", 60),
)For most code the difference is noise, and you should reach for readability first. But in a tight loop logging thousands of times a second, LogAttrs is the one to use. Add AddSource: true to HandlerOptions when you want file and line on every record, at a small cost.
Where does log/slog fit in the rest of your stack?
The real win isn't the JSON output. It's that slog.Handler is an interface with the API frozen in the standard library, so you write against slog everywhere and swap the backend whenever you need to.
That's a decision you get to stop making. Today you log with JSONHandler. Next quarter you drop in a faster community handler, or an OpenTelemetry handler that ships logs as OTel signals correlated with your traces, or a colorized dev handler for local runs. Your application code doesn't change, because it only ever talked to the slog API. The logging library debate that ate so many Go code reviews is settled by making the library boundary an interface you control.
Start with slog.SetDefault and a JSON handler this afternoon. Your old log calls keep working, your new code gets structured fields, and you've added zero dependencies to do it.
For the full reference, read the log/slog package docs and the official Go blog introduction to structured logging, which covers the design and the performance characteristics in depth.
Keep Reading
- How to Redact Secrets from Java Flight Recorder in JDK 27. The same problem on the JVM: stop secrets from leaking through your observability tooling.
- How to Speed Up ECS Auto Scaling with High-Resolution Metrics. Logs are one half of observability; metrics are the other half worth tuning.
- How to Implement Idempotency Keys in REST APIs the Right Way. Structured request IDs and idempotency keys are the two things that make a write endpoint debuggable in production.