25.6 log/slog: Structured Logging Built Into the Standard Library
Finally. For years, logging in Go felt like we were all collectively duct-taping our fmt.Printf statements into something resembling a professional application. We’d bolt on logrus or zap, which are fantastic, but it created a fragmented ecosystem. The Go team, in their infinite and sometimes frustrating wisdom, decided it was time to bring order to the chaos. Enter log/slog in Go 1.21: structured logging, right there in the standard library.
This is a big deal. It means we can now have rich, machine-readable logs without a third-party dependency, and more importantly, we can all start speaking the same language when it comes to output. No more guessing if a library you’re using will log in JSON or in a bizarre custom format that breaks your parser.
The Two Handlers: Text and JSON
slog is built around the concept of handlers. The two built-in ones are TextHandler and JSONHandler. You’ll create a logger by attaching it to a handler, and that handler defines the ultimate destiny of your log lines.
The TextHandler is for us humans. It writes key-value pairs in a vaguely logfmt style, which is great for reading in your terminal while you’re developing. The JSONHandler is for… well, everything else. It’s for when your logs are going to be ingested by a system like Loki, Elasticsearch, or Datadog. These systems eat JSON for breakfast.
package main
import (
"log/slog"
"os"
)
func main() {
// For human-friendly output in the terminal
textLogger := slog.New(slog.NewTextHandler(os.Stdout, nil))
textLogger.Info("user logged in", "user_id", 123, "ip", "192.168.1.1")
// For machine-friendly output to a file or system
file, _ := os.Create("app.log")
jsonLogger := slog.New(slog.NewJSONHandler(file, nil))
jsonLogger.Info("user logged in", "user_id", 123, "ip", "192.168.1.1")
}
Running this will give you two very different outputs:
time=2023-10-26T14:30:00.000-04:00 level=INFO msg="user logged in" user_id=123 ip=192.168.1.1
And in app.log:
{"time":"2023-10-26T14:30:00.000-04:00","level":"INFO","msg":"user logged in","user_id":123,"ip":"192.168.1.1"}
The choice here is straightforward: use TextHandler for local development and JSONHandler for production. The consistency is a godsend.
Attributes and Levels: The Meat and Potatoes
You log messages with levels (Debug, Info, Warn, Error) and attach structured attributes. The basic API uses a sequence of key-value pairs, which is simple but error-prone if you miscount.
// This works...
slog.Info("process completed", "duration_ms", 450, "success", true)
// ...this will panic and ruin your day. The keys and values must come in pairs.
slog.Error("process failed", "error", err, "retries_left") // PANIC: missing value for key
To avoid this foot-gun, slog provides the slog.Attr type and helper functions, which are more verbose but much safer. This is the style I recommend for anything more than a trivial, one-off log.
logger.Info("process completed",
slog.Int("duration_ms", 450),
slog.Bool("success", true),
slog.Group("request",
slog.String("path", "/api/v1/user"),
slog.String("method", "GET"),
),
)
Notice the slog.Group? That’s your best friend for keeping related attributes nested and organized, which prevents clobbering common keys like id or error and makes your JSON logs a joy to query.
Context and With-ness
Like any good modern Go library, slog plays nicely with context.Context. You can store a logger in a context and retrieve it later, which is essential for passing a logger with request-specific attributes (like a request ID) throughout your call stack.
But the real power is in With. The Logger.With method returns a new logger that always includes the given attributes. This is phenomenally useful.
func handleRequest(logger *slog.Logger, req *http.Request) {
// Create a new logger for this request that always includes the request_id
requestLogger := logger.With(
slog.String("request_id", generateRequestID()),
slog.String("path", req.URL.Path),
)
// Now every message logged with requestLogger has those attributes
requestLogger.Info("request started")
// do work...
requestLogger.Info("request completed")
}
This is the correct way to do it. Don’t manually add the request ID to every single log call; you will forget. Attach it to the logger once and be done with it.
The Rough Edges and Questionable Choices
It’s not all sunshine and rainbows. The design of slog has some… interesting quirks.
First, the basic API using alternating key-values is a mistake waiting to happen. It’s far too easy to mess up the order, and you get a runtime panic, not a compile-time error. Always prefer slog.Int(), slog.String(), etc., in anything resembling production code.
Second, the default global logger is just the old log package logger, wrapped. This feels like a bizarre backwards-compatibility hack. To set the default slog logger to be the global default (so functions like slog.Info use your fancy JSON config), you have to use slog.SetDefault. It’s an easy step to forget.
func main() {
myLogger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(myLogger) // Don't forget this!
// Now this uses your JSON logger, not the old text one.
slog.Info("this is now JSON!")
}
Finally, the handler options are passed in a single struct, which is very Go-like but can feel a bit opaque. You want to set the minimum level to Warn? You have to create a HandlerOptions struct.
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelWarn, // Only output Warn and Error logs
// AddSource: true, // This would add (expensive) source file/line info
})
logger := slog.New(handler)
Overall, slog is a massive win. It standardizes a critical piece of the ecosystem with a well-considered API. It has some wrinkles, but they’re ironed out easily enough with good practices. Start using it. Your future self, and the DevOps person who has to parse your logs, will thank you.