When text logs fail you
You deploy a Go service to a Kubernetes cluster. The logs stream to stdout. Everything looks fine locally. Then the alerting system fires because it can't find the error pattern. The log aggregator is treating your structured data as a blob of text. You need machine-readable output so tools can index, filter, and alert on specific fields. JSON logs solve this by turning every log line into a structured document.
Text logs are like handwritten notes. They are easy for humans to read but hard for machines to parse reliably. JSON logs are like filling out a form. Every field has a label and a value. The structure is consistent. Aggregation tools can extract level, msg, trace_id, and user_id without guessing where one field ends and another begins. The log/slog package in the standard library makes this straightforward. It provides a handler that serializes log records to JSON automatically.
The JSON handler
The slog package separates the logger from the output format. A logger collects the message and attributes. A handler decides how to write them. slog.NewJSONHandler returns a handler that formats records as JSON.
Here's the simplest way to get JSON output: create a logger with a JSON handler and write a record.
package main
import (
"log/slog"
"os"
)
func main() {
// JSONHandler writes log records as JSON to the provided io.Writer.
// os.Stdout captures the output so it flows to the terminal or container log stream.
handler := slog.NewJSONHandler(os.Stdout, nil)
logger := slog.New(handler)
// Key-value pairs follow the message.
// slog pairs them automatically: "port" becomes a key, 8080 becomes the value.
logger.Info("server started", "port", 8080, "env", "production")
}
# prints:
{"time":"2024-05-20T10:00:00Z","level":"INFO","msg":"server started","port":8080,"env":"production"}
The handler adds a timestamp and log level to every record automatically. When you call logger.Info, slog collects the message and the key-value arguments, passes them to the handler, and the handler marshals the result to JSON bytes. The output goes to os.Stdout. In a container environment, the runtime captures stdout and sends it to the log aggregation backend.
JSON logs turn noise into data. Structure enables automation.
Attributes and groups
Real services log more than a message. You usually need request IDs, user IDs, and error details. slog uses key-value pairs for attributes. You can nest attributes using slog.Group to keep the JSON organized.
Here's how to group related attributes under a single key.
package main
import (
"log/slog"
"os"
)
func main() {
handler := slog.NewJSONHandler(os.Stdout, nil)
logger := slog.New(handler)
// GroupValue creates a nested object in the JSON output.
// This keeps the top-level keys clean when you have many attributes.
userInfo := slog.GroupValue(
"id", 42,
"role", "admin",
)
logger.Info("user action",
"user", userInfo,
"action", "update_profile",
)
}
# prints:
{"time":"...","level":"INFO","msg":"user action","user":{"id":42,"role":"admin"},"action":"update_profile"}
Groups are useful for namespacing data. If you are logging metrics, you might group all metric attributes under a metrics key. If you are logging user data, group it under user. This prevents key collisions and makes the schema predictable.
Use slog.Group when you want to namespace a set of attributes under a single key to keep the JSON flat and organized.
Context and request scope
Loggers should travel with the request. You don't want to pass a logger through every function signature manually. Bind the logger to the context.Context at the entry point and retrieve it downstream.
Here's how to attach a logger to a context and use it in a handler.
package main
import (
"context"
"log/slog"
"os"
)
func main() {
// JSONHandler configures the output format.
// HandlerOptions can enable source tracking or level filtering.
opts := &slog.HandlerOptions{
AddSource: true,
}
handler := slog.NewJSONHandler(os.Stdout, opts)
rootLogger := slog.New(handler)
// Attach the logger to the context.
// Downstream functions retrieve it via LoggerFromContext.
ctx := context.WithValue(context.Background(), "logger", rootLogger)
handleRequest(ctx)
}
func handleRequest(ctx context.Context) {
logger := LoggerFromContext(ctx)
// With returns a new logger with persistent attributes.
// The original logger remains unchanged, ensuring thread safety.
reqLogger := logger.With("req_id", "abc-123", "method", "GET")
reqLogger.Info("request started")
err := simulateError()
if err != nil {
// Error logs include the error value and stack trace if available.
reqLogger.Error("processing failed", "error", err)
return
}
reqLogger.Info("request completed", "duration_ms", 42)
}
func simulateError() error {
return fmt.Errorf("database timeout")
}
func LoggerFromContext(ctx context.Context) *slog.Logger {
// Value lookup returns the logger bound to the context.
// The type assertion ensures we get a *slog.Logger.
if l, ok := ctx.Value("logger").(*slog.Logger); ok {
return l
}
// Fallback prevents nil pointer panics if the context is missing the logger.
return slog.Default()
}
The With method returns a new logger. It does not mutate the original. This is safe for concurrent use. You can pass the enriched logger to sub-functions without worrying about leaking attributes to other requests. The context.Context is the standard way to pass request-scoped data. Logger binding follows this pattern.
Context carries the logger. Attributes travel with the request.
Configuration and levels
You can control log levels dynamically. slog.HandlerOptions accepts a Level field. You can use a *slog.LevelVar to change the level at runtime without restarting. This is useful for debugging production issues. You can also use ReplaceAttr to modify attributes.
Here's how to configure a dynamic level and filter attributes.
package main
import (
"log/slog"
"os"
)
func main() {
// LevelVar allows changing the log level at runtime.
// This is useful for debugging without restarting the service.
level := new(slog.LevelVar)
opts := &slog.HandlerOptions{
Level: level,
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
// Drop the source attribute to reduce noise in production.
// Returning an empty Attr removes the key from the output.
if a.Key == slog.SourceKey {
return slog.Attr{}
}
return a
},
}
handler := slog.NewJSONHandler(os.Stdout, opts)
logger := slog.New(handler)
logger.Info("initial log")
// Switch to debug level to see more detail.
level.Set(slog.LevelDebug)
logger.Debug("debug log now visible")
}
ReplaceAttr runs for every attribute. It gives you a hook to sanitize data, rename keys, or drop attributes based on the environment. Use it to conform to external schemas or remove sensitive fields before they hit the log stream.
Log what you can parse. Never log secrets. Check Enabled before expensive work.
Pitfalls and errors
JSON logging has a few gotchas. If you pass an odd number of arguments to a log method, the program panics at runtime with slog: even number of arguments.... The compiler does not catch this because the arguments are variadic. Always pair keys and values.
JSON logs serialize everything you pass. If you log a user struct, you might dump passwords or tokens. Use slog.Group to namespace data, or filter fields manually with ReplaceAttr. Never log sensitive data.
JSON marshaling has a cost. In high-throughput paths, logging every micro-operation can degrade latency. Use logger.Enabled to check if a level is active before doing expensive work.
if logger.Enabled(ctx, slog.LevelDebug) {
// Expensive computation only happens if debug logging is on.
detail := computeExpensiveDetail()
logger.Debug("expensive check", "detail", detail)
}
The slog package is part of the standard library since Go 1.21. If you are on an older version, you need a third-party library. The gofmt tool handles code formatting. slog output is compact by default. If you need pretty printing for local dev, use a different handler or a post-processing tool. Don't fight the compact format in production.
When to use JSON logs
Use slog.NewJSONHandler when you need structured logs for aggregation tools like Elasticsearch, Splunk, or Datadog. Use slog.NewTextHandler when you are debugging locally and prefer human-readable output with aligned columns. Use a custom slog.Handler when you need to send logs to a specific destination like a file, a network socket, or a cloud provider's proprietary format. Use log.Printf with a custom SetOutput when you are maintaining legacy code that relies on the old log package and cannot migrate to slog yet. Use slog.Group when you want to namespace a set of attributes under a single key to keep the JSON flat and organized. Use logger.With when you have attributes that apply to all logs in a scope, such as a request ID or user ID.
Pick the handler that matches your destination. Structure wins over style.