Unstructured logging is a debugging tool. Structured logging is an observability tool. The difference: unstructured logs tell a human what happened; structured logs tell a machine what happened, and the machine can then tell a human efficiently.
Unstructured:
2022-11-23 10:58:34 ERROR failed to process order ORD-12345 for user usr_abc: connection refused
Structured:
{"time":"2022-11-23T10:58:34Z","level":"ERROR","msg":"failed to process order",
"order_id":"ORD-12345","user_id":"usr_abc","error":"connection refused",
"service":"order-service","version":"1.2.3"}
The structured log can be indexed, filtered, aggregated, and alerted on. order_id:ORD-12345 returns all logs for that order across all services. The unstructured log requires grep and hope.
The Options in 2022
log/slog (standard library, Go 1.21+): the official structured logging package. Standardises the API, reduces ecosystem fragmentation.
zerolog (github.com/rs/zerolog): zero-allocation structured logging, JSON output, good performance.
zap (go.uber.org/zap): similar performance to zerolog, slightly different API, widely used.
logrus: the old standard. JSON output, slow, large allocations. Not recommended for new code.
The performance comparison (all producing JSON, log level check that passes):
Library ns/op allocs/op bytes/op
──────────────────────────────────────────
slog 312 2 64
zerolog 118 0 0
zap 186 0 0
logrus 2,890 24 1,568
Zerolog’s zero-allocation design (it builds JSON directly into a pre-allocated buffer) makes it the fastest. Slog is the most portable (standard library). Zap is a reasonable middle ground.
For services logging thousands of times per second — a market data normaliser, an API gateway — the allocation difference matters. For a service logging hundreds of times per minute, any of these is fine.
What the Logging Call Should Look Like
The conventions that make structured logs useful:
| |
The principles:
msgis static:"failed to process order"not"failed to process order " + order.ID. The ID goes in a field. This makes log aggregation work — all failures cluster under the same message.- Error is a field:
erris logged as a structured field, not interpolated into the message. This allows filtering by error type, not by error text. - IDs are always logged: order ID, user ID, request ID — anything that lets you find all related log lines.
- No units in field names:
quantitynotquantity_lots. Units are implied by context or documented separately.
Context-Aware Logging
In a service handling concurrent requests, logs need to carry request-scoped context — request ID, user ID, trace ID — without threading these through every function signature.
The pattern: store a logger with bound fields in the context:
| |
Every log line emitted during this request automatically carries request_id, method, and path without the handler or service function passing them explicitly.
Log Levels as a Contract
Log levels are not just severity — they’re a contract about what the log consumer should do:
Level Meaning Who reads it
──────────────────────────────────────────────────────────────
DEBUG Detailed tracing for debugging Developer, only when debugging
INFO Normal operation events Monitoring, aggregation
WARN Unexpected but handled; watch this Monitoring, alerting if frequent
ERROR Operation failed; action may be needed Alerting
FATAL Process cannot continue Paging
The discipline that keeps logs useful:
Don’t log at DEBUG in production by default. DEBUG logs are for development. In production with default INFO level, DEBUG is free (the check returns false before any work is done). But if you enable DEBUG in production for investigation, it should be signal, not noise.
INFO should be sparse. If INFO logs fill GBs per day, nobody reads them. Each INFO event should be meaningful — request start/end, significant state changes, dependency calls. Not loop iterations.
WARN should be monitored. WARN means “this is unusual and may indicate a problem.” If you never look at WARNs, you’re ignoring signal. Have a weekly process of reviewing WARN volume — are they spiking? are there new ones?
ERROR means something needs attention. If an error is expected and handled gracefully, it might be WARN or INFO. If it’s truly an ERROR, someone should be looking at it.
The slog Standard (Go 1.21+)
With Go 1.21, log/slog is the standard library package. The API:
| |
The advantage of slog: standardised interface means library code can accept *slog.Logger and work with any backend. Previously, each library chose its own logging interface (often a custom interface), leading to adapter proliferation.
For new Go projects starting in 2023+, slog is the right default. For existing projects using zerolog or zap, the performance characteristics may justify staying — especially in allocation-sensitive hot paths.
What Matters More Than the Library
The library choice matters less than the conventions:
- Every log line has a static message
- Every log line has the relevant IDs (request, user, entity)
- Errors are structured fields with the full error value
- Context-scoped fields are automatically propagated
- Log levels mean what they say and are acted on accordingly
- Logs are machine-readable (JSON) in production
A team with good logging conventions using logrus will have better observability than a team that picked zerolog but uses it like fmt.Printf.