The problem with plain text logs
Your production API starts returning 500 errors at 2 AM. You SSH into the server and tail the log file. The output is a stream of unstructured strings. You grep for the failing endpoint and get four hundred lines of noise. Half of them are health checks. The rest mix request IDs, user IDs, and stack traces into single paragraphs. You spend twenty minutes writing a fragile regex just to isolate the real problem. This is why plain text logging fails under load. You need data you can query, not prose you have to parse.
What structured logging actually means
Structured logging treats every log line as a predictable data structure instead of a free-form sentence. Think of it like a database row. Each column has a fixed name and a known type. The slog package enforces this pattern by requiring alternating keys and values. You pass a message, then a list of attributes. The logger handles serialization, escaping, and type conversion. You get JSON or text output that machines can ingest without regex hacks. The standard library added slog in Go 1.21 to replace the old log package for anything that needs machine readability.
Your first slog logger
Here is the simplest way to create a logger and emit a single event.
package main
import (
"log/slog"
"os"
)
func main() {
// JSONHandler formats output as valid JSON lines.
// os.Stdout sends the stream to the terminal or container stdout.
handler := slog.NewJSONHandler(os.Stdout, nil)
logger := slog.New(handler)
// Info logs at the default level.
// Keys and values alternate. slog converts types automatically.
logger.Info("server started", "port", 8080, "env", "production")
}
The compiler checks the function signature at build time. Info expects a string message followed by variadic any arguments. If you pass an odd number of key-value pairs, the compiler rejects the program with slog: key-value pairs must have even length. At runtime, slog attaches a timestamp and the log level automatically. The handler serializes the attributes, writes the line, and flushes the buffer. You get a single JSON object per call.
Structured logs are data. Treat them like data.
How the runtime evaluates your attributes
slog does not format your values immediately when you call Info. It stores them in a temporary slice and passes them to the handler. The handler decides whether to write the line based on the configured level. If the line passes the filter, the handler iterates over the attributes and converts each value to a slog.Value. This lazy evaluation saves CPU cycles when you run your application with LevelWarn in production but LevelDebug in development. You pay the formatting cost only when the log actually gets written.
The handler also respects Go's convention of explicit error handling. If you pass an error value, slog automatically adds an error key to the output and includes the error message. You do not need to call .Error() yourself. The package also handles time.Time and context.Context natively. When you pass a context, slog extracts any slog attributes that were attached to it and merges them into the log line. This keeps your function signatures clean while preserving traceability across goroutine boundaries.
Adding context without repeating yourself
Real applications log the same context across dozens of function calls. Repeating requestID and userID on every line wastes CPU and clutters the output. slog solves this with the With method. It returns a new logger instance that carries the attached attributes forward.
package main
import (
"log/slog"
"os"
)
// handleRequest simulates an HTTP handler that logs multiple events.
func handleRequest(logger *slog.Logger, reqID string, userID string) {
// With creates a child logger that inherits the parent's handler.
// It attaches the request and user identifiers to every future call.
reqLogger := logger.With("req_id", reqID, "user_id", userID)
reqLogger.Info("request received", "method", "POST", "path", "/api/data")
// Simulate work. The child logger still carries the base attributes.
reqLogger.Debug("payload parsed", "size_bytes", 1024)
reqLogger.Info("request completed", "status", 200, "duration_ms", 42)
}
func main() {
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
// Level controls the minimum severity that gets written.
// slog.LevelDebug captures everything. Production usually uses Info.
Level: slog.LevelDebug,
})
baseLogger := slog.New(handler)
handleRequest(baseLogger, "abc-123", "usr-456")
}
The With method does not mutate the original logger. It returns a fresh instance with a merged attribute list. This matches Go's preference for explicit state over hidden mutation. You can pass the child logger through your call stack just like you pass a context.Context. The convention is to keep the logger close to the I/O boundary and attach context as early as possible. You avoid repeating keys, and you keep the hot path clean.
Attach context early. Pass the logger down. Never repeat yourself.
Handling custom types and groups
Plain structs do not serialize cleanly by default. If you pass a custom struct to slog, the logger falls back to fmt.Sprintf("%+v", v). The output becomes a single unstructured string inside your JSON line. You lose the ability to query individual fields. You have two options to fix this. You can unpack the struct manually and pass each field as a separate key-value pair. Or you can implement the slog.LogValuer interface on your type. The interface requires a single method, LogValue() slog.Value. slog calls this method during formatting and uses the returned value instead of the raw struct.
Groups let you namespace related attributes. You might want to separate application metadata from request metadata. slog.Group takes a key and a list of attributes, then nests them under that key in the output. The compiler will not catch structural mistakes. It only verifies that you passed an even number of arguments. You get slog: key-value pairs must have even length if you drop a value, but you get no warning if you nest a group inside another group unnecessarily. Keep groups flat. Use them only when you need to bundle third-party attributes or separate application metadata from request metadata.
Log concrete values. Flatten your groups. Never trust implicit formatting.
When things go wrong
Structured logging introduces a few runtime traps. The most common mistake is passing a function or a channel as a value. slog evaluates arguments lazily for performance, but it still needs to format them when the log line triggers. If you pass a channel, the formatter blocks waiting for a value that never arrives. The program deadlocks silently. If you pass a function, slog calls it during formatting. If that function panics, the panic escapes the logger and crashes your process. Always pass concrete values or use slog.Any to wrap complex types safely.
Another trap involves handler configuration. The default HandlerOptions use LevelInfo and disable source file/line reporting. If you enable AddSource: true, the handler captures the caller's file and line number using runtime.Caller. This adds a small allocation per log line. The performance cost is negligible for most applications, but it becomes visible in tight loops. You also need to remember that slog does not buffer output by default. Each call writes directly to the underlying io.Writer. If you need high throughput, wrap os.Stdout in a bufio.Writer or use a third-party handler that batches writes.
The community convention for error handling remains verbose by design. You will still write if err != nil { return err } before logging. slog does not change Go's error philosophy. It only makes the error data queryable. Keep your error checks explicit. Log the error with logger.Error("failed to process", "error", err). The package automatically extracts the error message and adds it to the output.
Pick the tool that matches your query needs, not your benchmark dreams.
Picking your logging strategy
Use slog when you need machine-readable logs with zero external dependencies. Use the legacy log package when you are writing a tiny CLI tool that only a human will ever read. Use a third-party library like zap or zerolog when you require extreme throughput and are willing to trade API simplicity for micro-optimizations. Use slog with a custom handler when you need to route logs to different destinations based on severity. Use plain fmt debugging when you are prototyping and will delete the code before merge.