Skip to content

Go slog: the Structured Logging I Stopped Hand-Rolling

Go slog: the Structured Logging I Stopped Hand-Rolling

For years my Go logging setup was the same handful of steps on every new service. Import a third-party logger, wire it to emit JSON, then spend twenty minutes deciding how to thread it through the code so handlers could actually reach it. I had a little internal package called logx that I copied between projects. It wasn’t good. It was just mine, and it was familiar, and familiar is a hard thing to give up.

Go 1.21 added log/slog to the standard library, and after avoiding it for a while out of pure habit, I deleted logx. Not refactored. Deleted. The standard library now does the part I actually cared about, which is structured logs with typed key and value pairs, and it does it well enough that reaching for an external logger needs a real justification now.

Here’s what slog replaced for me, where it’s genuinely good, and the one part I still find awkward.

What was wrong with plain log

Go always shipped a log package, and it was fine for exactly one job: printing a line of text so a human would see it. The trouble is that text is where log data goes to die. Here’s the kind of line my old code produced:

log.Printf("user %d updated order %d, status now %s", userID, orderID, status)

You can read that. A machine cannot, not reliably. If you want every log line for order 4821, you’re writing a regular expression against an English sentence. If you ship those logs to anything that indexes fields, the fields don’t exist. There’s just prose.

The standard log package also has no notion of severity. Every line is just a line. If you wanted to separate routine information from a genuine warning, you built that distinction yourself, usually with a second logger and a naming convention you’d forget within a month. So my logx package wasn’t solving an exotic problem. It was patching two holes that the standard library left open for a decade.

Structured logging fixes this by making each log line a set of key and value pairs instead of a sentence. The message is one field. The user id is another. The order id is another, stored as an actual number. Your log backend can then filter on order_id the way a database filters on a column. That’s the entire pitch, and it’s why the Go team wrote up structured logging as a first-class concern rather than leaving it to the ecosystem to solve five different ways.

slog, the version I actually use

Here’s that same line with slog:

slog.Info("order updated",
    "user_id", userID,
    "order_id", orderID,
    "status", status,
)

The first argument is the message, a constant string you can group on. Everything after it is key and value pairs. Finding one order is now a field match instead of a guess.

By default slog prints a readable line, which is what you want at your desk. In production I switch the handler to JSON once, at startup:

func main() {
    handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: slog.LevelInfo,
    })
    slog.SetDefault(slog.New(handler))

    slog.Info("service started", "port", 8080)
}

slog.SetDefault installs a package-level logger, so the bare slog.Info calls scattered through the code pick it up automatically. That one detail is why I stopped threading a logger through every function signature. For a small service the default logger is genuinely fine, and I say that as someone who spent years insisting it wasn’t.

Levels come built in, Debug through Error, and the HandlerOptions.Level in that snippet is what filters them. Set it to LevelInfo in production and your Debug lines cost almost nothing, because the handler drops them before it formats anything. There’s also a context-aware variant, slog.InfoContext, that threads a context.Context into the handler. Write a handler that reads a request id off the context once, and every call site stops needing to remember to attach it.

When a section of code should tag every line with the same context, slog.With returns a child logger that carries those fields for you:

reqLog := slog.With("request_id", reqID, "route", r.URL.Path)
reqLog.Info("handling request")
reqLog.Warn("slow query", "ms", elapsed)

Both of those lines come out carrying request_id and route without you repeating them. That’s the feature I lean on most, because request-scoped context is exactly the thing you forget to attach by hand.

Where slog is genuinely good

Two things make slog worth the switch beyond the obvious win of structured output.

The first is that it lives in the standard library, so it has no version of its own to track and no dependency to audit. A logging library that every service imports is a library you have to keep patched across every service. slog ships with the Go you already installed. The Go 1.21 release notes introduced it, and it has been stable since.

The second is the handler interface. slog separates the front end, the Info and Warn calls you write, from the back end, the handler that decides what a log line becomes. The standard library gives you a text handler and a JSON handler out of the box. If you need something else, you implement one interface, and every slog call in your program and in your dependencies routes through it. That means a library logging with slog can be told how to log by the application importing it, which is the coordination the ecosystem never quite managed before.

If you’re structuring a service from scratch, this pairs well with how I think about Go services in general, which I went into in a post on Go web frameworks. Logging is one of the few decisions you want to make early and then stop thinking about.

The part I still find awkward

slog isn’t perfect, and the rough edge shows up quickly: the key and value pairs are loosely typed in the form most people use.

When you write slog.Info("msg", "user_id", userID), nothing checks that a value follows every key. If you drop one, slog does its best and emits a line flagged as having a bad argument, at runtime, not at compile time. I’ve shipped that mistake. It’s the kind of typo a stricter API would have refused outright.

The Go team is aware of this. go vet ships an slog analyzer that catches some mismatched key and value pairs before they reach production, and it’s worth keeping in your build. But it doesn’t catch everything, and a vet warning is still a step you can ignore, not a wall the compiler puts in front of you.

slog does offer a stricter form through its Attr constructors:

slog.Info("order updated",
    slog.Int("order_id", orderID),
    slog.String("status", status),
)

This version is much harder to get wrong, because slog.Int won’t accept a string. It’s also noisier to type, and most code I read in the wild uses the loose form regardless. I’ve settled on a rule: loose form for ordinary logs, Attr constructors in the paths I care about most, anything touching money or auth. It’s a compromise, and I’d rather the compiler simply helped, which is a complaint I have about a few corners of Go and have made before in a post on Go error handling.

Where to start

If you’re starting a new Go service in 2026, use slog. There’s no reason to add a logging dependency before you’ve hit a concrete need the standard library can’t meet, and for most services that need never arrives.

If you have an existing service on an external logger, don’t tear it out for sport. But the next time you’re already in the logging code for some other reason, that’s your opening to switch.

Here’s the thing to try this week. Find the ugliest log.Printf in your codebase, the one with four values crammed into a sentence, and rewrite just that line as a slog.Info call with real keys. Send it to wherever your logs end up and try to filter on one of the fields. That single query, the one you could not run before, is the whole argument for structured logging, and it lands harder than anything I can write here. If you want to see the kind of Go services I build this into, that’s over on my portfolio.