How to Use slog (Structured Logging) in Go 1.21+

Use the log/slog package to create a logger and output structured JSON logs with key-value pairs for better machine readability.

The wall of text problem

You are debugging a production API at 2 AM. The logs show thousands of lines like 2024/05/12 03:14:22 request failed for user 8842 status 500. You need to find every failed request for user 8842. You write a regex. It breaks on the next deployment because the timestamp format changed. This is the wall of text problem. Unstructured logs force you to parse strings manually. Structured logging gives you discrete key-value pairs that survive formatting changes and scale to machine-readable output.

Structured logging in plain words

Structured logging treats every log entry as a small document. Instead of concatenating strings, you pass a message alongside labeled data points. Each data point has a key and a value. The logger collects these attributes and hands them to a handler. The handler decides how to render them. You can swap JSON for human-readable text without touching a single logging call. Think of it like a database row versus a paragraph. The row has columns you can query. The paragraph requires reading. slog brings this pattern to the standard library so you do not need to vendor a third-party package just to log key-value pairs.

Go convention dictates that public names start with a capital letter and private names start lowercase. slog follows this strictly. Info, Debug, and Error are exported methods. The internal Attr and Handler types are also exported because you will interact with them directly. The package avoids hidden magic. If you want a timestamp, the handler adds it. If you want a level, the logger checks it. Everything is explicit.

Your first structured logger

Here is the simplest way to create a JSON logger and emit a structured message.

package main

import (
	"log/slog"
	"os"
)

func main() {
	// JSON handler formats output for machines and CI pipelines
	handler := slog.NewJSONHandler(os.Stdout, nil)
	// Logger wraps the handler and enforces level filtering
	logger := slog.New(handler)
	// Info logs at INFO level with two typed attributes
	logger.Info("server started", "port", 8080, "env", "production")
}

Run this and you get a single line of valid JSON. The timestamp, level, message, and your custom keys are all machine-parseable. The nil in NewJSONHandler uses the default handler options, which include timestamps and level filtering. You can change those options by passing a *slog.HandlerOptions struct. The logger itself does not care about formatting. It only cares about collecting attributes and passing them downstream.

Handlers are not magic. They are just functions that implement the slog.Handler interface. The interface requires Enabled, Handle, and WithAttrs. This design keeps the logging call fast and pushes formatting to the edge. Trust the handler. Argue logic, not formatting.

What happens under the hood

When you call logger.Info, the function checks the current log level. If the level is below the threshold, it returns immediately. This lazy evaluation keeps logging cheap. If the level passes, slog collects your key-value pairs into an []Attr slice. It then passes the message and attributes to the handler. The handler marshals everything into bytes and writes to the configured io.Writer. The handler is the only part that knows about JSON, text, or custom formatting. The logger itself is format-agnostic. This separation means you can swap handlers at runtime or per-service without rewriting your logging calls.

Go convention accepts interfaces and returns structs. slog embodies this. You accept a slog.Handler when building a logger, but the package returns concrete *slog.Logger instances. You rarely need to implement the handler interface yourself. The standard library provides NewJSONHandler and NewTextHandler. Third-party packages can implement it to route logs to databases, message queues, or cloud providers. The contract is small and stable.

Grouping related attributes

Flat key-value pairs work for simple cases. Complex systems need nesting. If you log user_id, user_name, and user_role on every request, the JSON output becomes a wide table. slog solves this with WithGroup. Groups wrap a set of attributes under a single key, producing nested JSON objects.

Here is how you attach grouped metadata to a logger.

package main

import (
	"log/slog"
	"os"
)

func main() {
	// Text handler prints readable output for local development
	handler := slog.NewTextHandler(os.Stdout, nil)
	logger := slog.New(handler)

	// WithGroup nests subsequent attributes under "user"
	userLogger := logger.WithGroup("user")
	userLogger.Info("login attempt", "id", 42, "role", "admin")
}

The output places id and role inside a user object. You can nest groups recursively. WithGroup returns a new logger, just like With. This keeps the original logger clean. Go convention favors cheap value types. Strings are already cheap to pass by value, and slog attributes follow the same pattern. You do not need pointers for keys or values. The package handles allocation internally.

Groups keep your logs queryable. Log aggregators like Loki or CloudWatch rely on nested keys to build dashboards. Flat keys collide. Nested keys separate concerns. Use groups when your domain model has clear boundaries.

Logging in a real HTTP handler

Real applications need request-scoped data. You want every log line in a single HTTP request to carry the request ID and user ID. slog handles this with the With method, which returns a new logger with pre-bound attributes.

Here is a realistic HTTP server that attaches request metadata to a child logger.

package main

import (
	"log/slog"
	"net/http"
	"os"
)

// NewRequestLogger attaches request metadata to a child logger
func NewRequestLogger(base *slog.Logger, req *http.Request) *slog.Logger {
	// With creates a new logger that always includes these keys
	return base.With("req_id", req.Context().Value("req_id"), "method", req.Method)
}

func main() {
	// JSON handler formats output for production pipelines
	handler := slog.NewJSONHandler(os.Stdout, nil)
	base := slog.New(handler)

	mux := http.NewServeMux()
	// Handler closure captures the base logger
	mux.HandleFunc("/data", func(w http.ResponseWriter, r *http.Request) {
		// Derive a request-scoped logger without mutating the base
		log := NewRequestLogger(base, r)
		log.Info("processing request")
		w.Write([]byte("ok"))
	})

	// Server listens on port 8080
	http.ListenAndServe(":8080", mux)
}

The With method does not modify the original logger. It returns a new one that inherits the handler and appends the new attributes. This is safe for concurrent use. Every goroutine handling a request gets its own logger instance. The base logger stays clean. Go convention dictates that context.Context travels as the first parameter in long-running calls. You can attach a logger to the context using slog.ContextKey and slog.WithContext, but the With pattern above is simpler for HTTP handlers where the request lifecycle is short.

Context is plumbing. Run it through every long-lived call site.

Common traps and compiler complaints

Structured logging introduces a few traps. The most common is type mismatch. slog expects keys to be strings. If you pass an integer or a custom type as a key, the compiler rejects it with cannot use 123 (untyped int constant) as string value in argument. Values can be almost anything, but complex structs will log as their default string representation unless you implement the slog.LogValuer interface. Another trap is forgetting that handlers control timestamps. The default JSONHandler includes them, but if you write a custom handler, you must add the time attribute yourself.

Level filtering also catches people off guard. The default level is INFO. Calls to Debug will silently disappear unless you configure the handler with slog.HandlerOptions{Level: slog.LevelDebug}. Goroutine leaks are rare in logging, but they happen when you spawn a background goroutine to flush logs and forget to close the channel or respect context cancellation. Always tie long-running loggers to a context that cancels on shutdown.

The if err != nil { return err } pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. slog follows the same philosophy. If you want to log an error, you pass it as an attribute. The logger does not guess. It records what you give it. Do not hide errors behind silent logging calls. Surface them explicitly.

The worst goroutine bug is the one that never logs.

When to reach for slog

Use slog when you need structured, parseable logs without adding external dependencies. Use the legacy log package when you are writing a tiny CLI tool and only need quick debug prints. Use a third-party library like zap or zerolog when you need extreme performance tuning, sampling, or advanced sink routing. Use plain fmt when you are prototyping and will delete the code before production.

Where to go next