Structured Logging in Go: Patterns for Production Services - NextGenBeing Structured Logging in Go: Patterns for Production Services - NextGenBeing
Back to discoveries

Structured Logging in Go: Patterns for Production Services

Logging is the cheapest observability tool you have, and for most services it's the one you'll reach for first when something breaks at 3 a.m. Yet a surprising number of Go services still emit…

DevOps 24 min read
Bekzod Erkinov

Bekzod Erkinov

Jul 28, 2026 0 views
Structured Logging in Go: Patterns for Production Services
Photo by Haberdoedas II on Unsplash
Size:
Height:
📖 24 min read 📝 7,938 words 👁 Focus mode: ✨ Eye care:

Listen to Article

Loading...
0:00 / 0:00
0:00 0:00
Low High
0% 100%
⏸ Paused ▶️ Now playing... Ready to play ✓ Finished
Table of contents · 16 sections

Structured Logging in Go: Patterns for Production Services

Logging is the cheapest observability tool you have, and for most services it's the one you'll reach for first when something breaks at 3 a.m. Yet a surprising number of Go services still emit logs that look like this:

2026/07/24 09:14:22 user 4812 failed to charge card: insufficient funds (attempt 3)

That line is fine for a human reading a terminal. It's nearly useless for a machine. You can't reliably filter by user ID, you can't aggregate by error type, and you can't build an alert on "attempt >= 3" without writing a fragile regex. Structured logging fixes this by emitting logs as key/value data — usually JSON — so that both humans and log pipelines can parse them without guessing.

This tutorial covers structured logging in Go end to end: the standard library's log/slog, the patterns that hold up in production, the mistakes that bite you, and where the popular third-party libraries fit. Every code block is runnable Go (1.21+).


Table of contents

  1. What "structured" actually means
  2. log/slog: the standard library baseline
  3. Levels, and how to choose them
  4. Attributes, groups, and the With pattern
  5. Context propagation: the request-scoped logger
  6. HTTP middleware: request IDs and access logs
  7. Errors: logging them once, with the right shape
  8. Writing a custom handler
  9. Redaction and sensitive data
  10. Performance: allocation, sampling, and the Enabled fast path
  11. Trace correlation: connecting logs to OpenTelemetry
  12. Testing your logging
  13. slog vs zap vs zerolog
  14. A production checklist

1. What "structured" actually means

A structured log record is a set of typed fields, not a formatted sentence. The same event above, structured as JSON:

{
  "time": "2026-07-24T09:14:22.041Z",
  "level": "ERROR",
  "msg": "charge failed",
  "user_id": 4812,
  "reason": "insufficient_funds",
  "attempt": 3,
  "service": "billing"
}

The msg is now a stable, low-cardinality string you can group by. Everything variable — the user, the reason, the attempt count — is a discrete field. In your log backend (Loki, Elasticsearch, CloudWatch, Datadog, BigQuery, whatever) you can now write:

  • level:ERROR AND reason:insufficient_funds — a filter, not a regex.
  • count by user_id where attempt >= 3 — an aggregation.
  • An alert on the rate of charge failed events, independent of the surrounding text.

Two principles fall out of this and drive almost every decision later in the tutorial:

  1. The message is a constant. Put variable data in fields, never interpolated into msg. "charge failed" — not fmt.Sprintf("charge failed for user %d", id). If you interpolate, you've re-created the unparseable string you were trying to escape, and every distinct user ID becomes a distinct "message," which wrecks aggregation and blows up cardinality.
  2. Fields are typed. attempt is the number 3, not the string "3". This matters for range queries and math in your backend.

2. log/slog: the standard library baseline

Since Go 1.21, structured logging is in the standard library as log/slog. Before that, the community standardized on zap (Uber) and zerolog. slog was deliberately designed with a pluggable backend so those libraries can act as slog handlers — meaning you can write against the standard interface and swap the engine underneath. For most new services, start with slog and don't add a dependency until you've measured a reason to.

The three moving parts

slog separates three concerns:

  • slog.Logger — the front end you call (.Info, .Error, …). Cheap, safe to copy.
  • slog.Handler — the backend that formats and writes records. This is the interface you implement or swap.
  • slog.Record — one log event: time, level, message, and attributes.

The built-in handlers are slog.NewTextHandler (logfmt-style key=value) and slog.NewJSONHandler (one JSON object per line). In production you almost always want JSON.

Hello, structured world

package main

import (
	"log/slog"
	"os"
)

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
		Level: slog.LevelInfo,
	}))

	logger.Info("charge failed",
		"user_id", 4812,
		"reason", "insufficient_funds",
		"attempt", 3,
	)
}

Output (one line, pretty-printed here):

{"time":"2026-07-24T09:14:22.041Z","level":"INFO","msg":"charge failed",
 "user_id":4812,"reason":"insufficient_funds","attempt":3}

The variadic key, value, key, value, … form is convenient but untyped — the compiler won't catch a missing value. For hot paths and correctness, prefer the typed slog.Attr constructors, which also allocate less:

logger.Info("charge failed",
	slog.Int("user_id", 4812),
	slog.String("reason", "insufficient_funds"),
	slog.Int("attempt", 3),
)

There are constructors for the common types: slog.String, slog.Int, slog.Int64, slog.Uint64, slog.Float64, slog.Bool, slog.Time, slog.Duration, slog.Any (reflection-based fallback), and slog.Group.

The default logger and the bridge from log

slog ships a package-level default so libraries and quick code can call slog.Info(...) without threading a logger around. Set it once at startup:

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
	slog.SetDefault(logger)

	slog.Info("service starting", "version", buildVersion)
}

A crucial side effect: slog.SetDefault also redirects the old log package through slog. So any dependency still calling log.Printf — and there are many — gets funneled into your structured pipeline instead of writing raw lines to stderr. That single call is often the highest-leverage thing you do when migrating an existing service.

Recommendation: In real services, still prefer passing an explicit *slog.Logger (see §5). Reserve the global default for third-party code you don't control and for genuinely process-global events like startup.


3. Levels, and how to choose them

slog defines four levels as integers, spaced apart so you can slot custom levels between them:

LevelDebug = -4
LevelInfo  = 0
LevelWarn  = 4
LevelError = 8

The spacing is intentional — a TRACE at -8 or a NOTICE at 2 is legal. Use them with restraint; every custom level is something your on-call engineer has to learn.

A pragmatic rubric that scales across teams:

Level Use it when… On-call reaction
Error An operation failed and a human or a retry must handle it. A request 500'd; a background job exhausted retries. Should be alertable. Every ERROR is a small promise that something is genuinely wrong.
Warn Something unexpected happened but the service recovered. A retry succeeded; a deprecated endpoint was hit; a cache missed and fell back. Watch trends, don't page.
Info A significant, expected business or lifecycle event. Service started; request completed; payment captured. The default production level.
Debug Developer detail useful when reproducing a problem. Full payloads, branch decisions, timing of sub-steps. Off in production; toggle on to investigate.

Two rules that prevent most level-related pain:

  • ERROR must be actionable. If nobody would ever do anything about a log line, it is not an error. "User submitted an invalid form" is Info or Warn, not Error — the user's fault isn't your incident. Alert fatigue is what happens when this rule slips.
  • Don't log-and-return-error. Logging an error and returning it means it gets logged again by the caller — sometimes several times up the stack. Pick one layer (usually the top, where the request is handled) to log. See §7.

Make the level runtime-configurable

You want to raise the log level to Debug on a running service without a redeploy. slog.LevelVar is an atomic, mutable level built exactly for this:

var logLevel = new(slog.LevelVar) // defaults to LevelInfo

func main() {
	handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
		Level: logLevel, // the handler reads this on every record
	})
	slog.SetDefault(slog.New(handler))

	// e.g. flip to debug from a signal, admin endpoint, or config watch:
	// logLevel.Set(slog.LevelDebug)
}

Wire logLevel.Set(...) to a SIGHUP handler, an authenticated /admin/loglevel endpoint, or your config system, and you can turn on debug logging for a single misbehaving pod while you watch it.


4. Attributes, groups, and the With pattern

With: bind fields once, carry them everywhere

Logger.With returns a new logger that prepends a fixed set of attributes to every record it emits. This is the workhorse for context that's constant across many log lines:

// At startup — process-wide identity on every single log line.
base := slog.New(slog.NewJSONHandler(os.Stdout, nil)).With(
	slog.String("service", "billing"),
	slog.String("env", os.Getenv("APP_ENV")),
	slog.String("version", buildVersion),
)

// Per request — derive a child that carries request-scoped identity.
reqLog := base.With(
	slog.String("request_id", reqID),
	slog.Int("user_id", userID),
)

reqLog.Info("charge started")          // both service+env+version AND request_id+user_id
reqLog.Warn("retrying payment gateway") // same — no repetition at the call site

With is also a performance optimization: a good handler pre-formats the bound attributes once when you call With, rather than re-serializing them on every log call. The built-in JSON and text handlers do exactly this. So base.With(...) at request start is cheaper than repeating those attributes on every Info call — and it's impossible to forget one.

Groups: namespacing to prevent collisions

slog.Group nests attributes under a key, which keeps names from colliding and makes the output navigable:

logger.Info("request complete",
	slog.Group("http",
		slog.String("method", "POST"),
		slog.String("path", "/charge"),
		slog.Int("status", 402),
	),
	slog.Group("db",
		slog.Int("queries", 3),
		slog.Duration("time", 14*time.Millisecond),
	),
)
{"msg":"request complete",
 "http":{"method":"POST","path":"/charge","status":402},
 "db":{"queries":3,"time":14000000}}

Now http.status and a hypothetical db.status never clash, and your backend can index the nested structure. You can also open a group for all subsequent attributes on a derived logger with WithGroup:

httpLog := reqLog.WithGroup("http")
httpLog.Info("handled", slog.Int("status", 200)) // -> {"http":{"status":200, ...}}

LogValuer: control how a type logs itself

Implement slog.LogValuer on your own types to control their log representation in one place. This is the idiomatic hook for redaction (see §9) and for flattening a struct into useful fields:

type User struct {
	ID    int
	Email string
	Token string // secret — must never hit the logs
}

// LogValue is called lazily by the handler, only if the record is actually emitted.
func (u User) LogValue() slog.Value {
	return slog.GroupValue(
		slog.Int("id", u.ID),
		slog.String("email", redactEmail(u.Email)),
		// Token deliberately omitted.
	)
}

Now logger.Info("login", slog.Any("user", someUser)) logs the safe projection automatically, everywhere, and there's exactly one place to audit. Because LogValue() is called lazily — only when the handler decides the record will be emitted — it's also a way to defer expensive formatting past a level check.


5. Context propagation: the request-scoped logger

The single most important production pattern: every log line for a given request should carry the same correlating fields (request ID, trace ID, user ID) so you can pull up the entire story of one request with a single query. There are two ways to move a logger through your call graph, and they solve different problems.

Option A — pass the *slog.Logger explicitly

Thread the logger as a parameter or a struct field. This is the most explicit and testable approach, and the one to prefer when you control the function signatures.

func (s *Server) handleCharge(w http.ResponseWriter, r *http.Request) {
	log := s.logger.With(slog.String("request_id", requestID(r)))
	if err := s.charge(r.Context(), log, chargeReq); err != nil {
		log.Error("charge failed", slog.Any("err", err))
		http.Error(w, "payment failed", http.StatusPaymentRequired)
		return
	}
}

func (s *Server) charge(ctx context.Context, log *slog.Logger, req ChargeRequest) error {
	log.Info("contacting gateway", slog.String("gateway", req.Gateway))
	// ...
}

Option B — carry the logger in context.Context

Passing a logger to every function is noisy, and you often can't change third-party signatures. The common idiom is to stash the request logger in the context.Context that Go already threads everywhere, and provide helpers to get it back out:

package logctx

import (
	"context"
	"log/slog"
)

type ctxKey struct{}

// Into returns a new context carrying log.
func Into(ctx context.Context, log *slog.Logger) context.Context {
	return context.WithValue(ctx, ctxKey{}, log)
}

// From returns the context's logger, or the default if none is set.
// It never returns nil — callers can always log.
func From(ctx context.Context) *slog.Logger {
	if log, ok := ctx.Value(ctxKey{}).(*slog.Logger); ok {
		return log
	}
	return slog.Default()
}

Usage anywhere down the stack, without plumbing a logger parameter:

func chargeGateway(ctx context.Context, req ChargeRequest) error {
	log := logctx.From(ctx)
	log.InfoContext(ctx, "contacting gateway", slog.String("gateway", req.Gateway))
	// ...
}

Note InfoContext (and DebugContext, WarnContext, ErrorContext): these pass the ctx into the handler. That's how a handler can pull trace IDs out of the context automatically — see §11. Get in the habit of using the …Context variants everywhere a context is in scope; there's no downside, and it enables context-aware handlers for free.

Which option? Use context (B) for the ambient request-scoped logger — it's what the …Context methods and OTel integration are built around. Reserve explicit passing (A) for hot, well-defined internal APIs where you want the dependency visible in the signature. Most production codebases use B for propagation and let a middleware (§6) put the logger into the context once.

A caveat worth stating plainly: don't overstuff the context logger. Every With you add at the edge is copied onto every record for the whole request. Bind the handful of fields that identify the request (request ID, trace ID, user ID, route); resist binding large payloads.


6. HTTP middleware: request IDs and access logs

Middleware is where request-scoped logging comes together. A single piece of middleware should: generate or extract a request ID, build a request logger, put it in the context, and emit one structured access-log line per request with the status and latency.

package httplog

import (
	"context"
	"log/slog"
	"net/http"
	"time"

	"github.com/google/uuid"
)

// statusRecorder captures the status code the handler writes.
type statusRecorder struct {
	http.ResponseWriter
	status int
	bytes  int
}

func (r *statusRecorder) WriteHeader(code int) {
	r.status = code
	r.ResponseWriter.WriteHeader(code)
}

func (r *statusRecorder) Write(b []byte) (int, error) {
	if r.status == 0 {
		r.status = http.StatusOK // WriteHeader was never called explicitly
	}
	n, err := r.ResponseWriter.Write(b)
	r.bytes += n
	return n, err
}

func Middleware(base *slog.Logger) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			start := time.Now()

			// Prefer an inbound request ID (from a gateway/LB); else mint one.
			reqID := r.Header.Get("X-Request-Id")
			if reqID == "" {
				reqID = uuid.NewString()
			}
			w.Header().Set("X-Request-Id", reqID) // echo it back to the caller

			log := base.With(
				slog.String("request_id", reqID),
				slog.String("method", r.Method),
				slog.String("path", r.URL.Path),
			)
			ctx := logctx.Into(r.Context(), log)

			rec := &statusRecorder{ResponseWriter: w}
			next.ServeHTTP(rec, r.WithContext(ctx))

			// One access-log line per request, with the outcome.
			log.LogAttrs(ctx, slog.LevelInfo, "request completed",
				slog.Int("status", rec.status),
				slog.Int("bytes", rec.bytes),
				slog.Duration("duration", time.Since(start)),
				slog.String("remote_addr", r.RemoteAddr),
			)
		})
	}
}

LogAttrs is the most efficient logging call: it takes a context, a level, a message, and a []slog.Attr with no variadic any boxing. Use it in hot paths like access logging.

Every handler downstream now does logctx.From(ctx) and gets a logger already stamped with request_id, method, and path. One query — request_id:"abc-123" — returns the complete, ordered narrative of that request across every layer that logged.

A few production refinements:

  • Trust boundary on X-Request-Id. Accepting an inbound ID is great for tracing across services, but a hostile client can send anything. Validate/limit its length, and never use it anywhere it could cause injection (it's a field value, so JSON-encoding protects you, but log-forging into a text handler is a real concern).
  • Skip noisy paths. Health checks (/healthz) and metrics scrapes (/metrics) hit constantly. Emit them at Debug, or skip the access log for them entirely, or your Info stream becomes 90% noise.
  • Panic recovery. Wrap the handler in a recover() that logs the panic with the request logger (so it carries the request ID) at Error before re-panicking or returning 500. A panic that logs without the request ID is a panic you can't correlate.

7. Errors: logging them once, with the right shape

Error logging is where most log noise and most confusion originates. Two anti-patterns dominate:

Anti-pattern 1 — log and return. Every layer logs the error as it bubbles up, so a single failure produces five near-identical ERROR lines from five stack frames, and your error rate looks 5× worse than reality.

// DON'T: this error will be logged again by every caller up the stack.
func (s *Store) Save(ctx context.Context, u User) error {
	if err := s.db.Insert(ctx, u); err != nil {
		s.log.Error("insert failed", slog.Any("err", err)) // logged here...
		return err                                          // ...and logged again above
	}
	return nil
}

The rule: return errors down low, log them once up high. Add context to the error as it travels using fmt.Errorf with %w, and log it a single time at the boundary where you decide the request's fate (the HTTP handler, the job runner, the message consumer):

// Low level: wrap with context, don't log.
func (s *Store) Save(ctx context.Context, u User) error {
	if err := s.db.Insert(ctx, u); err != nil {
		return fmt.Errorf("save user %d: %w", u.ID, err)
	}
	return nil
}

// Boundary: log exactly once, with the full wrapped chain.
func (h *Handler) createUser(w http.ResponseWriter, r *http.Request) {
	log := logctx.From(r.Context())
	if err := h.store.Save(r.Context(), u); err != nil {
		log.ErrorContext(r.Context(), "create user failed", slog.Any("err", err))
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
}

The wrapped error string — "save user 4812: insert: connection refused" — carries the causal chain, logged once.

Give errors a real field, not just a string

slog.Any("err", err) calls the error's Error() and stores the string. That's the baseline. To make errors queryable, attach structured fields. A clean pattern is to define errors that expose their own attributes, then a helper that extracts them:

// A typed error that knows how to describe itself as log attributes.
type CodedError struct {
	Code string
	Op   string
	err  error
}

func (e *CodedError) Error() string { return e.Op + ": " + e.err.Error() }
func (e *CodedError) Unwrap() error { return e.err }

func (e *CodedError) LogValue() slog.Value {
	return slog.GroupValue(
		slog.String("code", e.Code),
		slog.String("op", e.Op),
		slog.String("cause", e.err.Error()),
	)
}

Now log.Error("charge failed", slog.Any("err", codedErr)) yields:

"err":{"code":"insufficient_funds","op":"charge","cause":"gateway declined: 402"}

You can alert on err.code:insufficient_funds directly. For a lighter-weight approach without custom error types, just add the fields at the log site:

log.ErrorContext(ctx, "charge failed",
	slog.String("err", err.Error()),
	slog.String("err_code", classify(err)), // your own mapping to a stable code
)

Stack traces

slog does not capture stack traces by default, and for most errors you don't want them — the wrapped %w chain tells you the path. When you do want a trace (an unexpected panic, a truly opaque failure), capture it deliberately:

log.ErrorContext(ctx, "unhandled panic",
	slog.Any("panic", rec),
	slog.String("stack", string(debug.Stack())),
)

Reserve stacks for panics and genuinely surprising errors; a stack on every expected error is expensive and drowns the signal.


8. Writing a custom handler

Sooner or later you need behavior the built-in handlers don't provide: pulling trace IDs from the context, adding a field to every record, routing errors to a second sink, or reformatting for a specific backend. You do this by implementing slog.Handler. The interface is four methods:

type Handler interface {
	Enabled(ctx context.Context, level Level) bool
	Handle(ctx context.Context, r Record) error
	WithAttrs(attrs []Attr) Handler
	WithGroup(name string) Handler
}

The most common and useful pattern is a wrapping handler that decorates records and delegates the actual formatting to an inner handler. Here's one that copies selected values from the context onto every record — the idiomatic way to auto-attach trace/request IDs without touching call sites:

package ctxhandler

import (
	"context"
	"log/slog"
)

// Handler enriches every record with attributes pulled from the context.
type Handler struct {
	inner slog.Handler
	keys  []contextKey // context keys to lift into the log record
}

type contextKey struct {
	ctxKey   any    // the key used with context.WithValue
	logField string // the attribute name to emit
}

func New(inner slog.Handler, keys []contextKey) *Handler {
	return &Handler{inner: inner, keys: keys}
}

func (h *Handler) Enabled(ctx context.Context, level slog.Level) bool {
	return h.inner.Enabled(ctx, level)
}

func (h *Handler) Handle(ctx context.Context, r slog.Record) error {
	for _, k := range h.keys {
		if v := ctx.Value(k.ctxKey); v != nil {
			r.AddAttrs(slog.Any(k.logField, v))
		}
	}
	return h.inner.Handle(ctx, r)
}

// WithAttrs and WithGroup MUST return a new Handler that wraps the inner's
// derived handler — otherwise the bound attributes/groups are silently dropped.
func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler {
	return &Handler{inner: h.inner.WithAttrs(attrs), keys: h.keys}
}

func (h *Handler) WithGroup(name string) slog.Handler {
	return &Handler{inner: h.inner.WithGroup(name), keys: h.keys}
}

Three rules the slog docs make explicit, and that are easy to get wrong:

  1. WithAttrs and WithGroup must not mutate the receiver. They return a new handler. Handlers are shared across goroutines; mutating in place is a data race. Always delegate to h.inner.WithAttrs(...) and wrap the result.
  2. Handle must be safe for concurrent use and should honor Enabled semantics (though Logger already checks Enabled before calling Handle, so you rarely re-check).
  3. Record's attributes should be treated as owned by you inside Handle, but if you clone a record to hold onto it, use r.Clone() — the attribute storage can be shared.

For anything beyond a thin wrapper — routing, buffering, fan-out — reach for the community slog-multi package before hand-rolling, and study the standard library's commonHandler as a reference implementation.


9. Redaction and sensitive data

Logging a password, a card number, a session token, or a raw email into a system that ships logs to a third-party vendor is a genuine incident — often a reportable one under GDPR/PCI. Structured logging makes redaction easier than string logs because the sensitive data lives in named fields you can intercept. Defense in depth means doing it at more than one layer.

Layer 1 — types redact themselves (LogValuer)

The strongest guarantee: a secret type that cannot be logged in the clear because its LogValue never exposes the raw value.

type Secret string

func (Secret) LogValue() slog.Value { return slog.StringValue("REDACTED") }
func (s Secret) Reveal() string      { return string(s) } // explicit, greppable, auditable

type Credentials struct {
	Username string
	Password Secret
}

Now logger.Info("auth", slog.Any("creds", creds)) can never leak the password — any code path that logs a Secret prints REDACTED. To use the value you must call .Reveal(), which is easy to find in review and in a grep.

Layer 2 — a redacting handler (ReplaceAttr / custom Handle)

Types you don't own (a third-party struct, a raw map) can't implement LogValuer. Catch those with HandlerOptions.ReplaceAttr, a function the built-in handlers call for every attribute, letting you rewrite or drop it by key:

var sensitiveKeys = map[string]bool{
	"password": true, "token": true, "authorization": true,
	"secret": true, "api_key": true, "set-cookie": true,
}

handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
	ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
		if sensitiveKeys[strings.ToLower(a.Key)] {
			return slog.String(a.Key, "REDACTED")
		}
		return a
	},
})

ReplaceAttr is also how you customize built-in fields: rename "msg" to "message" for a backend that expects it, reformat "time", or drop the "time" field entirely when your log shipper stamps its own.

Caveat: ReplaceAttr runs on every attribute of every emitted record and adds per-call overhead. Keep it cheap — a map lookup, not a regex sweep — and prefer LogValuer (Layer 1) for the types you own, since it's both safer and faster.

What to never log, as a default

Passwords, tokens, API keys, session cookies, full card numbers or CVVs, government IDs, raw request bodies on auth endpoints, and Authorization headers. When in doubt about PII like emails, log a hash or a masked form (j***@example.com) so you retain the ability to correlate without storing the raw value.


10. Performance: allocation, sampling, and the Enabled fast path

For most services logging is not the bottleneck, and you should not prematurely optimize it. But high-throughput paths (a proxy, a hot request handler, a tight loop) can make logging matter, and a few habits keep it cheap.

The level check is nearly free — but the arguments aren't

logger.Debug(...) checks the level and returns immediately if Debug is disabled. But the arguments are evaluated before the call, so an expensive argument is paid for even when the line is dropped:

// BAD: json.Marshal runs even when Debug is off — the call is skipped, the work isn't.
logger.Debug("state", slog.String("dump", string(mustJSON(bigObject))))

Two fixes. First, LogValuer defers the work — LogValue() is only called if the record is emitted:

type lazyJSON struct{ v any }

func (l lazyJSON) LogValue() slog.Value { return slog.StringValue(string(mustJSON(l.v))) }

logger.Debug("state", slog.Any("dump", lazyJSON{bigObject})) // Marshal only if Debug is on

Second, for genuinely expensive blocks, gate them explicitly:

if logger.Enabled(ctx, slog.LevelDebug) {
	logger.Debug("state", slog.String("dump", string(mustJSON(bigObject))))
}

Prefer LogAttrs and typed constructors in hot paths

The variadic ...any API boxes every argument into an interface, which allocates. LogAttrs(ctx, level, msg, ...slog.Attr) with typed constructors (slog.Int, slog.String) avoids the boxing and is the lowest-allocation path in slog. In an access-log middleware that runs on every request, this is worth doing.

Pre-bind with With

As noted in §4, With pre-formats bound attributes once. Binding request_id once per request via With is cheaper and less error-prone than passing it on every call within the request.

Sampling: don't log a million identical lines

When a downstream dependency fails, a busy service can emit the same error thousands of times per second — expensive to write, expensive to store, and impossible to read. Sampling logs 1 in N of a repetitive event, or the first N per interval then a summary. slog has no built-in sampler; you add one as a handler. The pattern (simplified):

// SampleHandler emits at most `first` records per key per window, then drops the rest.
// Real implementations (e.g. zap's sampler, samber/slog-sampling) also count drops.
func (h *SampleHandler) Handle(ctx context.Context, r slog.Record) error {
	key := r.Level.String() + r.Message // sample by level+message
	if h.counter.Allow(key) {           // token-bucket / per-window counter
		return h.inner.Handle(ctx, r)
	}
	return nil // dropped
}

Sample the repetitive, high-volume events (per-request debug, expected-and-common warnings). Never sample the rare, important ones — a unique fatal error must always get through. Zap ships a mature sampler; for slog, samber/slog-sampling is the common choice. When you sample, log the drop count periodically so a reader knows the number they see is "1 of many," not "1 total."

Where logs go, and blocking

Write to os.Stdout/os.Stderr and let the platform (systemd, Docker, Kubernetes, your log agent) collect them — this is the twelve-factor approach and it's the right default. If you wrap the writer, remember the built-in handlers hold an internal mutex around the write, so a slow writer blocks the logging goroutine (and thus the request). Never point a handler directly at a network socket that can stall; write to stdout and let a separate agent ship asynchronously. If you must buffer in-process, do it behind an async, bounded, drop-on-full channel — and count the drops.


11. Trace correlation: connecting logs to OpenTelemetry

In a distributed system, the payoff move is joining your logs to your traces: click a slow span in your tracing UI and pull up exactly the log lines emitted during it. That join key is the trace ID (and span ID). The clean way to add them is a context-aware handler that reads the active span out of the context — which is why §5 pushed you to use the …Context logging methods.

import "go.opentelemetry.io/otel/trace"

// traceHandler injects the active span's IDs into every record.
type traceHandler struct{ slog.Handler }

func (h traceHandler) Handle(ctx context.Context, r slog.Record) error {
	if sc := trace.SpanContextFromContext(ctx); sc.IsValid() {
		r.AddAttrs(
			slog.String("trace_id", sc.TraceID().String()),
			slog.String("span_id", sc.SpanID().String()),
		)
	}
	return h.Handler.Handle(ctx, r)
}

func (h traceHandler) WithAttrs(a []slog.Attr) slog.Handler {
	return traceHandler{h.Handler.WithAttrs(a)}
}
func (h traceHandler) WithGroup(name string) slog.Handler {
	return traceHandler{h.Handler.WithGroup(name)}
}

Wrap your JSON handler in traceHandler, use InfoContext/ErrorContext with the request context, and every log line inside a span now carries trace_id. Configure your backend to recognize that field (Datadog, Grafana/Tempo, Honeycomb all support a trace-ID log correlation) and logs↔traces navigation lights up.

The official go.opentelemetry.io/contrib/bridges/otelslog bridge goes further, letting slog records be exported through the OTel logs pipeline itself. For most teams, though, the small handler above — emit trace_id as a normal field to stdout — delivers the correlation with far less machinery. Start there.


12. Testing your logging

Logging is behavior, and some of it is worth testing: that an error path logs at Error, that a redaction actually redacts, that a required field is present. Because a handler writes to an io.Writer, you can capture output into a buffer and assert on the parsed JSON.

func TestChargeFailureLogsError(t *testing.T) {
	var buf bytes.Buffer
	logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))

	svc := NewService(logger)
	_ = svc.Charge(context.Background(), ChargeRequest{ /* forces failure */ })

	var rec map[string]any
	if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &rec); err != nil {
		t.Fatalf("log output was not valid JSON: %v", err)
	}
	if rec["level"] != "ERROR" {
		t.Errorf("level = %v, want ERROR", rec["level"])
	}
	if rec["msg"] != "charge failed" {
		t.Errorf("msg = %v, want %q", rec["msg"], "charge failed")
	}
	if _, ok := rec["request_id"]; !ok {
		t.Error("expected request_id field to be present")
	}
}

Two conveniences worth knowing:

  • slogtest (testing/slogtest) exists to verify that a custom handler you wrote conforms to the slog.Handler contract — it feeds your handler a battery of records and checks the output. If you implement §8, run it through slogtest.
  • A redaction test is a security test. Explicitly assert that logging a struct with a secret produces REDACTED and not the raw value. This guards against a future refactor quietly reintroducing a leak.

Don't over-test logging — asserting on every field of every info line makes your tests brittle and couples them to wording. Test the contract that matters: level of error paths, presence of correlation fields, and redaction.


13. slog vs zap vs zerolog

slog is the right default, but it helps to know the landscape and when to deviate.

log/slog zap (Uber) zerolog (rs)
Dependency Standard library Third-party Third-party
Ergonomics Clean; typed Attr + variadic Verbose (zap.Field) or slower SugaredLogger Fluent chained builder
Raw speed Fast; good enough for ~all services Fastest tier, battle-tested Fastest tier, minimal alloc
Zero-alloc paths Low-alloc, not zero Near-zero with zapcore.Field Near-zero by design
Ecosystem Growing fast; now the common target Huge, mature Large
Best when New services; you want the std interface Extreme throughput; existing zap codebase Extreme throughput; you like the API

The decisive fact: slog's handler interface means the choice is not permanent. You can write all your application code against *slog.Logger and back it with zap or zerolog by using a bridging handler (slog-zap, slog-zerolog, or zap's own zapslog), swapping the engine without touching call sites. So the pragmatic path is:

  1. Start with slog + JSONHandler. No dependency, good performance, the standard interface.
  2. Profile before switching. Only if logging shows up as a real cost in a real profile — usually a very hot path emitting millions of lines — consider zap/zerolog.
  3. If you switch, keep the slog front end via a bridge, so your code and tests don't move and you can change your mind later.

For an existing zap or zerolog codebase that works, there's no urgency to migrate. slog's value is highest for new code and for unifying a codebase that has three logging styles.


14. A production checklist

A concrete list to audit a service against.

Format & output

  • JSON handler in production; text handler only for local dev.
  • Logs go to stdout/stderr; the platform ships them. No direct network writers on the log path.
  • slog.SetDefault is called at startup so stray log.Printf from dependencies is captured.

Fields & correlation

  • Every line carries service, env, and version (bound once via With at startup).
  • Every request carries a request_id, set by middleware and echoed in the response header.
  • trace_id/span_id are attached when tracing is active, via a context-aware handler.
  • msg values are constant strings; all variable data is in typed fields.

Levels

  • Level is runtime-adjustable via slog.LevelVar (signal, admin endpoint, or config).
  • Every Error is genuinely actionable; user-input failures are Warn/Info.
  • Health/metrics endpoints are excluded from the Info access log.

Errors

  • Errors are wrapped with %w and logged once, at the request/job boundary.
  • Error logs carry a stable, queryable err_code where it matters — not only a free-text string.
  • Stack traces are captured for panics and truly opaque failures only.

Safety

  • Secret types implement LogValuer and print REDACTED.
  • A ReplaceAttr or handler-level redactor catches sensitive keys on types you don't own.
  • A test asserts that secrets are redacted, and it runs in CI.

Performance

  • Hot paths use LogAttrs with typed constructors, not the variadic any form.
  • Expensive debug arguments are deferred behind LogValuer or an Enabled gate.
  • Repetitive high-volume events are sampled, and drops are counted and reported.

Closing

Structured logging is a small change in habit that pays off every time you're staring at a production incident. The whole discipline reduces to a handful of ideas: make the message constant and the data typed; give every request a correlating ID and carry it through the context; log each error exactly once, up high, with a queryable shape; and never let a secret reach the writer. Start with log/slog and the standard JSON handler — it's in the toolchain, it's fast enough, and its handler interface means none of your choices are permanent. Add a custom handler when you need context enrichment, a sampler when volume demands it, and a third-party engine only when a profiler tells you to.

The logs you write today are the answers your future self gets to search at 3 a.m. Make them structured, and make them count.

Bekzod Erkinov

Bekzod Erkinov

Author

Founder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.

🎁 Free guide

Get the AI-Assisted Developer's Field Guide

The workflow, prompts, and tools I use to ship faster with AI — free when you subscribe. Plus new deep-dives in your inbox. No spam, unsubscribe anytime.

Comments (0)

Please log in to leave a comment.

Log In

Related Articles

Don't miss the next deep dive

Get one well-researched tutorial in your inbox each week. No spam, unsubscribe anytime.