Go 1.21 added log/slog to the standard library. Structured logging without a dependency.
Estimated Reading Time : 8m
What’s wrong with log.Println
log.Printf("user %s logged in from %s", username, ip)
This produces a human-readable string. Parsing it programmatically — in a log aggregator, alerting system, or grep pipeline — means writing regex against an unstructured format that changes every time you edit the message.
Structured logging emits key-value pairs:
time=2024-09-10T14:30:00Z level=INFO msg="user logged in" user=alice ip=10.0.0.1
Every field is named and extractable. slog brings this to the standard library.
Basic usage
package main
import "log/slog"
func main() {
slog.Info("user logged in",
"user", "alice",
"ip", "10.0.0.1",
)
}
Output:
2024/09/10 14:30:00 INFO user logged in user=alice ip=10.0.0.1
Key-value pairs are passed as alternating arguments after the message. slog.Info, slog.Debug, slog.Warn, and slog.Error are the four log levels.
Handlers
A handler controls the output format. slog ships with two:
TextHandler
Human-readable key-value format:
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
logger.Info("request", "method", "GET", "path", "/users", "status", 200)
time=2024-09-10T14:30:00.000Z level=INFO msg=request method=GET path=/users status=200
JSONHandler
Machine-readable JSON — what most log aggregators (Datadog, Loki, CloudWatch) expect:
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("request", "method", "GET", "path", "/users", "status", 200)
{"time":"2024-09-10T14:30:00.000Z","level":"INFO","msg":"request","method":"GET","path":"/users","status":200}
Setting the default logger
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
}))
slog.SetDefault(logger)
// Now slog.Info(), slog.Debug(), etc. use the JSON handler
slog.Info("server starting", "port", 8080)
}
slog.SetDefault also redirects the old log package’s output through the new handler, so third-party libraries using log.Println get structured output too.
Log levels
slog.Debug("cache miss", "key", cacheKey) // -4
slog.Info("request handled", "status", 200) // 0
slog.Warn("rate limit approaching", "pct", 85) // 4
slog.Error("query failed", "err", err) // 8
Set the minimum level in HandlerOptions:
slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelWarn, // only Warn and Error
})
Typed attributes
Alternating key, value arguments work but aren’t type-checked. Use slog.Attr for explicit typing:
slog.LogAttrs(ctx, slog.LevelInfo, "request",
slog.String("method", "GET"),
slog.Int("status", 200),
slog.Duration("latency", elapsed),
slog.String("path", "/users"),
)
slog.String, slog.Int, slog.Duration, slog.Bool, slog.Time, slog.Any — each creates a typed attribute. This avoids the overhead of interface boxing and catches type mismatches at compile time.
Groups
Groups namespace related attributes:
slog.Info("request",
slog.Group("req",
slog.String("method", "GET"),
slog.String("path", "/users"),
),
slog.Group("resp",
slog.Int("status", 200),
slog.Duration("latency", 12*time.Millisecond),
),
)
JSON output:
{"time":"...","level":"INFO","msg":"request","req":{"method":"GET","path":"/users"},"resp":{"status":200,"latency":"12ms"}}
Logger with context
Attach common fields to a logger so you don’t repeat them on every call:
logger := slog.Default().With(
"service", "api",
"version", "1.4.2",
)
logger.Info("started") // includes service=api version=1.4.2 automatically
logger.Info("request", "path", "/users") // also includes service and version
With returns a new logger — the original is unchanged. This is useful for per-request loggers:
func handleRequest(w http.ResponseWriter, r *http.Request) {
logger := slog.Default().With(
"request_id", r.Header.Get("X-Request-ID"),
"method", r.Method,
"path", r.URL.Path,
)
logger.Info("request started")
// ... handle request ...
logger.Info("request completed", "status", statusCode)
}
Passing loggers through context
ctx := context.Background()
// Store logger in context
logger := slog.Default().With("request_id", "abc-123")
ctx = context.WithValue(ctx, loggerKey, logger)
// Retrieve later
slog.InfoContext(ctx, "processing", "step", "validation")
For a cleaner API, use a helper:
func LoggerFromContext(ctx context.Context) *slog.Logger {
if logger, ok := ctx.Value(loggerKey).(*slog.Logger); ok {
return logger
}
return slog.Default()
}
Migrating from other loggers
If you’re using logrus or zap, slog covers the same ground with a smaller API surface:
| Feature | logrus | zap | slog |
|---|---|---|---|
| Structured fields | .WithField() |
.With() |
.With() |
| JSON output | built-in | built-in | JSONHandler |
| Log levels | 7 levels | 5 levels | 4 levels |
| Performance | slowest | fastest | fast |
| Dependency | external | external | standard library |
slog is faster than logrus and slightly slower than zap in benchmarks. For most applications, the difference is negligible — and having no dependency is worth a lot.
The bottom line
Use slog.NewJSONHandler in production, slog.NewTextHandler in development. Attach context with With. Use slog.SetDefault once at startup and the rest of your application just calls slog.Info().