How to Set Up Structured Logging in Go with log/slog
Writing
DEVOPS & INFRASTRUCTURE
Published November 6, 20269 min read

How to Set Up Structured Logging in Go with log/slog

Learn how to set up structured logging in Go with the standard library log/slog package: JSON handlers, attributes, groups, context fields, and secret redaction.

Rabinarayan Patra - Software Development Engineer

By Rabinarayan Patra

SDE II at Amazon

structured-logging-golog-sloggolangobservabilitylogginggo-1-21

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.00

That 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=prod

The 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 too

That 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 Info

But 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 restart

On 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

Frequently Asked Questions

What is log/slog in Go?

log/slog is the structured logging package added to Go's standard library in Go 1.21. It produces machine-parseable logs (JSON or key-value text) with typed attributes, log levels, and pluggable handlers, so you don't need a third-party library like zap or logrus for structured logging in Go.

Is slog faster than zap or logrus?

slog is allocation-conscious and competitive with popular libraries for most workloads, especially through its slog.LogAttrs path that avoids boxing values into an empty interface. For the vast majority of services the standard library is fast enough, and you keep the option to plug a high-performance handler behind the same slog API.

How do I redact secrets from slog output?

Use the ReplaceAttr function in slog.HandlerOptions. It runs on every attribute before the line is written, so you can match keys like password or token and return a redacted value, drop the attribute entirely, or reformat it.

Does slog replace the standard log package?

Not exactly. slog is the new structured logger, but calling slog.SetDefault also routes the older log package's output through slog, so existing log.Print calls keep working and become structured. You can migrate gradually instead of rewriting every call site.

Rabinarayan Patra - Software Development Engineer

Rabinarayan Patra

SDE II at Amazon. Previously at ThoughtClan Technologies building systems that processed 700M+ daily transactions. I write about Java, Spring Boot, microservices, and the things I figure out along the way. More about me →

X (Twitter)LinkedIn

Stay in the loop

Get the latest articles on system design, frontend and backend development, and emerging tech trends, straight to your inbox. No spam.