Structured Logging in Go with slog
Learn Go's slog package for structured, leveled logging with JSON output, custom handlers, and context-aware log enrichment.
What you'll learn
- ✓How to use slog for structured JSON logging in production
- ✓Creating custom handlers and middleware for log enrichment
- ✓Passing loggers through context for request-scoped fields
Prerequisites
- •Basic Go knowledge
- •Understanding of HTTP handlers and middleware
Go 1.21 added log/slog to the standard library, giving Go developers a first-class structured logging package. No more choosing between log, logrus, zap, and zerolog for basic needs. This guide covers slog from basics to production patterns.
Why Structured Logging
Traditional log.Printf produces unstructured text:
2026/07/01 10:00:00 failed to fetch user u-123: connection refused
This is hard to parse, filter, and aggregate. Structured logging produces key-value pairs:
{"time":"2026-07-01T10:00:00Z","level":"ERROR","msg":"failed to fetch user","user_id":"u-123","error":"connection refused"}
Log aggregators like Datadog, Loki, and CloudWatch can index these fields and let you query them.
Getting Started with slog
Basic Usage
package main
import "log/slog"
func main() {
slog.Info("server starting", "port", 8080, "env", "production")
slog.Warn("cache miss", "key", "user:123")
slog.Error("request failed", "status", 500, "path", "/api/users")
}
Default output (text format):
2026/07/01 10:00:00 INFO server starting port=8080 env=production
2026/07/01 10:00:00 WARN cache miss key=user:123
2026/07/01 10:00:00 ERROR request failed status=500 path=/api/users
JSON Handler for Production
Switch to JSON output by setting a JSONHandler as the default.
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
slog.Info("server starting", "port", 8080)
}
Output:
{"time":"2026-07-01T10:00:00.000Z","level":"INFO","msg":"server starting","port":8080}
Log Levels
slog has four built-in levels: Debug, Info, Warn, and Error.
opts := &slog.HandlerOptions{
Level: slog.LevelDebug, // show all levels
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))
logger.Debug("detailed info", "step", "parsing")
logger.Info("normal operation", "items", 42)
logger.Warn("approaching limit", "usage", 0.85)
logger.Error("operation failed", "error", err)
Dynamic Log Level
Use slog.LevelVar to change the level at runtime (e.g., via an admin endpoint).
var logLevel slog.LevelVar
logLevel.Set(slog.LevelInfo)
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: &logLevel,
}))
// Later, set to debug via an HTTP endpoint
http.HandleFunc("/admin/debug", func(w http.ResponseWriter, r *http.Request) {
logLevel.Set(slog.LevelDebug)
w.Write([]byte("debug logging enabled"))
})
Structured Attributes
Using slog.Attr for Type Safety
Instead of alternating key-value pairs, use typed attributes.
slog.Info("user created",
slog.String("user_id", user.ID),
slog.String("email", user.Email),
slog.Int("age", user.Age),
slog.Duration("latency", elapsed),
slog.Time("created_at", user.CreatedAt),
)
Grouping Attributes
Group related fields under a namespace.
slog.Info("request completed",
slog.Group("request",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.String("remote_addr", r.RemoteAddr),
),
slog.Group("response",
slog.Int("status", status),
slog.Duration("duration", elapsed),
),
)
JSON output:
{
"time": "2026-07-01T10:00:00Z",
"level": "INFO",
"msg": "request completed",
"request": {
"method": "GET",
"path": "/api/users",
"remote_addr": "192.168.1.1:5432"
},
"response": {
"status": 200,
"duration": "12.34ms"
}
}
Logger with Preset Fields
Use logger.With() to create a child logger that includes fields on every log line.
func NewOrderService(db *sql.DB, logger *slog.Logger) *OrderService {
return &OrderService{
db: db,
logger: logger.With("service", "orders"),
}
}
func (s *OrderService) PlaceOrder(ctx context.Context, order Order) error {
log := s.logger.With("order_id", order.ID)
log.Info("placing order", "total", order.Total)
if err := s.chargePayment(ctx, order); err != nil {
log.Error("payment failed", "error", err)
return err
}
log.Info("order placed successfully")
return nil
}
Every log line from PlaceOrder automatically includes service=orders and order_id=....
Context-Aware Logging
Storing Logger in Context
A common pattern is to enrich the logger in middleware and pass it via context.
type ctxKey string
const loggerKey ctxKey = "logger"
func LoggerFromContext(ctx context.Context) *slog.Logger {
if l, ok := ctx.Value(loggerKey).(*slog.Logger); ok {
return l
}
return slog.Default()
}
func WithLogger(ctx context.Context, l *slog.Logger) context.Context {
return context.WithValue(ctx, loggerKey, l)
}
Logging Middleware
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := r.Header.Get("X-Request-ID")
if requestID == "" {
requestID = uuid.NewString()
}
logger := slog.Default().With(
"request_id", requestID,
"method", r.Method,
"path", r.URL.Path,
)
ctx := WithLogger(r.Context(), logger)
start := time.Now()
wrapped := &statusRecorder{ResponseWriter: w, status: 200}
next.ServeHTTP(wrapped, r.WithContext(ctx))
logger.Info("request completed",
"status", wrapped.status,
"duration", time.Since(start),
)
})
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
Now handlers can retrieve the enriched logger:
func HandleGetUser(w http.ResponseWriter, r *http.Request) {
log := LoggerFromContext(r.Context())
log.Info("fetching user", "user_id", userID)
// logs include request_id, method, and path automatically
}
Custom Handler
Implement slog.Handler to customize log output, add fields, or filter sensitive data.
type SensitiveFieldHandler struct {
inner slog.Handler
redacted map[string]bool
}
func NewSensitiveFieldHandler(inner slog.Handler, fields ...string) *SensitiveFieldHandler {
redacted := make(map[string]bool)
for _, f := range fields {
redacted[f] = true
}
return &SensitiveFieldHandler{inner: inner, redacted: redacted}
}
func (h *SensitiveFieldHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.inner.Enabled(ctx, level)
}
func (h *SensitiveFieldHandler) Handle(ctx context.Context, r slog.Record) error {
var filtered []slog.Attr
r.Attrs(func(a slog.Attr) bool {
if h.redacted[a.Key] {
filtered = append(filtered, slog.String(a.Key, "[REDACTED]"))
} else {
filtered = append(filtered, a)
}
return true
})
newRecord := slog.NewRecord(r.Time, r.Level, r.Message, r.PC)
newRecord.AddAttrs(filtered...)
return h.inner.Handle(ctx, newRecord)
}
func (h *SensitiveFieldHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &SensitiveFieldHandler{inner: h.inner.WithAttrs(attrs), redacted: h.redacted}
}
func (h *SensitiveFieldHandler) WithGroup(name string) slog.Handler {
return &SensitiveFieldHandler{inner: h.inner.WithGroup(name), redacted: h.redacted}
}
Usage:
jsonHandler := slog.NewJSONHandler(os.Stdout, nil)
safeHandler := NewSensitiveFieldHandler(jsonHandler, "password", "token", "ssn")
logger := slog.New(safeHandler)
logger.Info("user login", "email", "alice@example.com", "password", "secret123")
// password field logged as [REDACTED]
Error Logging Pattern
Create a helper that logs errors with stack context.
func LogError(ctx context.Context, msg string, err error, attrs ...any) {
log := LoggerFromContext(ctx)
args := append([]any{"error", err.Error()}, attrs...)
log.Error(msg, args...)
}
Usage:
if err := db.QueryRowContext(ctx, query).Scan(&result); err != nil {
LogError(ctx, "database query failed", err, "query", "get_user", "user_id", id)
return err
}
Summary
- Use
slog.NewJSONHandlerfor production andslog.NewTextHandlerfor development. - Set
slog.SetDefaultonce at startup to configure the global logger. - Use
logger.With()to create child loggers with preset fields. - Store enriched loggers in context via middleware for request-scoped logging.
- Use
slog.Groupto namespace related attributes. - Use
slog.LevelVarfor runtime log level changes. - Implement
slog.Handlerto redact sensitive fields or customize output. - Structure your logs so aggregation tools can index and query them effectively.
Related articles
- Go Go pprof Profiling Tutorial
Profile Go programs with pprof: enable the HTTP endpoint, capture CPU and heap profiles, read flame graphs, and find the hot spot that is actually costing you latency.
- DevOps Log Aggregation with ELK Stack: Complete Setup Guide
Set up centralized log aggregation with Elasticsearch, Logstash, and Kibana. Learn to collect, parse, and visualize logs from multiple services in one dashboard.
- DevOps Observability: Logs, Metrics, and Traces Explained
Understand the three pillars of observability. Learn how logs, metrics, and distributed traces work together to give you full visibility into production systems.
- Kubernetes Kubernetes Logging with Fluent Bit and Elasticsearch
Set up centralized Kubernetes logging with Fluent Bit, Elasticsearch, and Kibana. Covers DaemonSets, parsers, filters, and production tuning.