A request fails at 2 AM. The on-call engineer greps through logs looking for a trace ID, finds seventeen lines for the same request scattered across formats — some plain text, some JSON, some with timestamps in three different timezones — and reconstructs what happened by hand. Structured logging exists so that reconstruction never has to happen. Yet many Go services still ship logs as formatted strings, and the usual reason is that structured logging feels like ceremony: another library, another API to learn, another dependency to pin. That excuse stopped being valid when Go 1.21 shipped log/slog in the standard library.
This post covers how to use slog properly: the handler model that makes it fast, context propagation so every log line carries its trace data automatically, sampling so a flood of errors cannot drown you in cost, and dynamic level control so you can turn on debug output in production without a restart or redeploy. Everything here compiles against the standard library — no third-party logging dependency required.
Why String Formatting Fails at Scale
Classic Go logging looks like this:
log.Printf("user %s checkout failed for order %d: %v", userID, orderID, err)
It is readable for a human, and that is the problem. The log consumer is a human. Any machine consumer — a log shipper, a query engine, an alerting rule — has to parse free-form text back into fields, and parsing conventions drift across codebases. Searching for every checkout failure becomes a regex exercise. Filtering by tenant becomes impossible unless someone remembered to put the tenant ID in a parseable position in every relevant message.
Structured logging inverts the model. The log record is data first — a set of typed key-value pairs plus a message — and rendering to text is a presentation concern. The cost shows up in two places: the allocation on the hot path, and the verbosity of writing attributes on every call. slog attacks both.
The slog Handler Model
slog separates what gets logged from how it gets serialized. You construct a *slog.Logger, which is just a thin, immutable value holding a slog.Handler and a level. The handler does the actual work. The standard library ships two: slog.NewJSONHandler for machine consumption and slog.NewTextHandler for local development.
package main
import (
"log/slog"
"os"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
slog.Info("order processed",
"order_id", 8412,
"tenant", "acme",
"total_usd", 129.99,
)
}
// {"time":"2026-09-29T03:05:11.402+03:00","level":"INFO","msg":"order processed","order_id":8412,"tenant":"acme","total_usd":129.99}
Two details matter for performance. First, attributes are constructed as slog.Attr values with explicit kinds, so there is no reflection — key = value pairs pass through a small interface, and each value carries its type. Second, level checking happens before attribute construction. If the handler is configured at LevelInfo, a call to slog.Debug returns before any of its arguments are processed, so debug logging in hot paths is nearly free when disabled — provided you use the package-level methods rather than building attribute slices eagerly yourself.
There is one trap worth naming: the variadic form slog.Info(msg, "k", v) allocates to build the argument slice even when the level is disabled. For extremely hot paths, the log/slog package documents the LogValuer interface for lazy attribute evaluation, and the strongest optimization is structural — gate the call:
if logger.Enabled(ctx, slog.LevelDebug) {
logger.Debug("queue depth detail", "depths", expensiveSnapshot(q))
}
Context Propagation: Logs That Know Their Request
The most common structured-logging failure is logs that lack request context — every line is well-formed JSON, but correlating the five lines emitted while handling one request means reading each one and matching request IDs by eye. The fix is to attach request-scoped fields to the logger, once, at the edge, and let the enriched logger flow through the call stack via context.Context.
package middleware
import (
"context"
"log/slog"
"net/http"
"github.com/google/uuid"
)
type ctxKey int
const loggerKey ctxKey = 1
// WithLogger stores a request-scoped logger in the context.
func WithLogger(ctx context.Context, l *slog.Logger) context.Context {
return context.WithValue(ctx, loggerKey, l)
}
// FromContext retrieves the request-scoped logger, falling back to the
// package default when no enriched logger is present.
func FromContext(ctx context.Context) *slog.Logger {
if l, ok := ctx.Value(loggerKey).(*slog.Logger); ok {
return l
}
return slog.Default()
}
func RequestLogger(base *slog.Logger, 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 := base.With(
"request_id", requestID,
"method", r.Method,
"path", r.URL.Path,
)
ctx := WithLogger(r.Context(), logger)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Deeper in the stack, handlers and services pull the logger from the context — never from a global — and every attribute set at the edge is already attached:
func handleCheckout(ctx context.Context, req CheckoutRequest) error {
logger := middleware.FromContext(ctx)
if err := chargeCard(ctx, req); err != nil {
logger.Error("checkout failed", "order_id", req.OrderID, "err", err)
return err
}
logger.Info("checkout complete", "order_id", req.OrderID)
return nil
}
Because Logger.With returns a new immutable logger, there is no shared mutable state to race on, and a background task spawned from a request can inherit the same enriched logger by passing the context along. One enrichment point, correlation everywhere.
If you are running OpenTelemetry, there is a shortcut: the otelslog bridge implements a handler that reads trace and span IDs from the context in the context and attaches them to every record, so logs and traces correlate without any custom middleware. Point your log pipeline and your trace pipeline at the same backend and the join happens by ID.
Sampling: Surviving the Error Flood
A failure in a retry loop can generate thousands of identical error records per second. Each one is individually “important” and collectively useless — they double your log volume at the exact moment you least want it, and they can push the logging bill past what the outage itself costs. Sampling caps the damage while keeping the signal.
The standard library does not ship a sampling handler, but slog.Handler is a one-method-plus-helper interface, so a capped-source sampler is small. This one passes the first N records with a given message through untouched, then keeps only every Kth:
package sampling
import (
"context"
"log/slog"
"sync"
)
// SamplingHandler passes the first maxSame records per distinct message
// through, then forwards only every keepEvery-th record after that.
type SamplingHandler struct {
inner slog.Handler
maxSame int
keepEvery int
mu *sync.Mutex
seen map[string]int
total map[string]int
}
func New(inner slog.Handler, maxSame, keepEvery int) *SamplingHandler {
return &SamplingHandler{
inner: inner,
maxSame: maxSame,
keepEvery: keepEvery,
seen: make(map[string]int),
total: make(map[string]int),
mu: &sync.Mutex{},
}
}
func (h *SamplingHandler) Enabled(ctx context.Context, l slog.Level) bool {
return h.inner.Enabled(ctx, l)
}
func (h *SamplingHandler) Handle(ctx context.Context, r slog.Record) error {
key := r.Message
h.mu.Lock()
h.seen[key]++
h.total[key]++
n := h.seen[key]
t := h.total[key]
h.mu.Unlock()
if n <= h.maxSame || (t-h.maxSame)%h.keepEvery == 0 {
return h.inner.Handle(ctx, r)
}
return nil
}
func (h *SamplingHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &SamplingHandler{inner: h.inner.WithAttrs(attrs), maxSame: h.maxSame, keepEvery: h.keepEvery, mu: h.mu, seen: h.seen, total: h.total}
}
func (h *SamplingHandler) WithGroup(name string) slog.Handler {
return &SamplingHandler{inner: h.inner.WithGroup(name), maxSame: h.maxSame, keepEvery: h.keepEvery, mu: h.mu, seen: h.seen, total: h.total}
}
A few design notes. Keying on the message groups the flood from one failing operation without collapsing distinct errors. The mutex is on the logging path, but logging handlers in this style are typically already behind a lock downstream, and the critical section is two map increments. Sampling at the application layer also has a subtle benefit over sampling in the log shipper: the decision is made before serialization and network I/O, so the shipper queue never absorbs the flood in the first place.
For production-grade needs, slog-multi provides composable middlewares including fan-out and sampling, and most vendor SDK bridges wrap their own samplers. Start with the standard-library-sized version, and reach for a library when the requirements grow.
Dynamic Levels: Debug On Demand in Production
The classic production debugging ritual is: notice the problem, realize you need debug logs, redeploy with a config change, and watch the incident end before your rollout completes. Dynamic level control removes the redeploy. The mechanism slog gives you is slog.LevelVar, an atomic level holder that can be swapped at runtime:
package main
import (
"encoding/json"
"log/slog"
"net/http"
"os"
)
func main() {
var level slog.LevelVar
level.Set(slog.LevelInfo)
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: &level}))
mux := http.NewServeMux()
mux.HandleFunc("POST /admin/log-level", func(w http.ResponseWriter, r *http.Request) {
var req struct {
Level string `json:"level"`
}
if err := decodeJSON(r, &req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
var target slog.Level
if err := target.UnmarshalText([]byte(req.Level)); err != nil {
http.Error(w, "unknown level", http.StatusBadRequest)
return
}
logger.Info("log level changed", "level", target, "remote", r.RemoteAddr)
level.Set(target)
w.WriteHeader(http.StatusNoContent)
})
http.ListenAndServe(":8081", mux)
}
func decodeJSON(r *http.Request, v any) error {
defer r.Body.Close()
return json.NewDecoder(r.Body).Decode(v)
}
Three operational rules make this safe. Protect the endpoint — it sits behind your internal admin network or authentication middleware, since an attacker who can flip levels to DEBUG has just widened the log-leak surface. Revert on a timer — set the level back to baseline after a TTL, or your debug flood becomes the new normal and the cost is permanent. And treat level changes as events — log the change itself, with the actor who made it, at the current level before applying it.
The same LevelVar pattern supports per-scope levels: pass a different *slog.LevelVar into different handlers for noisy subsystems — turn one chatty dependency up to DEBUG without raising the floor for the whole process. Wrap two handlers in a multi-handler (a small Handle loop over a slice) and each subsystem can be tuned independently.
Pitfalls Worth Avoiding
Don’t log sensitive values as attributes. Structured logs are easier to query, which makes them easier to leak from; tokens and personal data need redaction before they reach the handler. Don’t use slog.SetDefault casually in libraries — set a default in main only, and inject loggers through constructors or context in everything below. Don’t embed high-cardinality values (user IDs, request bodies) in the message string; messages should be constant per event type so sampling and grouping work, with variable data in attributes. And don’t log-and-return the same error at every layer — pick one: return it wrapped, or log it, not both.
Wrapping Up
The migration path for an existing service is short: construct a JSON handler in main, set it as the default, add the request-logger middleware, and convert the highest-value call sites first — errors and state transitions, not every line. From there, sampling and dynamic levels are additive changes of maybe fifty lines each. The payoff compounds: every log line becomes queryable, every request becomes correlated, and the 2 AM archaeology session becomes a five-minute filter query.