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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// With zerolog:
log.Error().
    Err(err).
    Str("order_id", order.ID).
    Str("user_id", order.UserID).
    Str("symbol", order.Symbol).
    Int64("quantity", order.Quantity).
    Msg("failed to process order")

// With slog:
slog.Error("failed to process order",
    "order_id", order.ID,
    "user_id", order.UserID,
    "symbol", order.Symbol,
    "quantity", order.Quantity,
    "error", err,
)

The principles:

  • msg is 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: err is 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: quantity not quantity_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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// Middleware: bind request-scoped fields to the logger in context
func requestLogger(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.New().String()
        }

        logger := log.With().
            Str("request_id", requestID).
            Str("method", r.Method).
            Str("path", r.URL.Path).
            Logger()

        ctx := logger.WithContext(r.Context())
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// In handler or service — extract logger from context:
func (s *Service) ProcessOrder(ctx context.Context, order Order) error {
    logger := zerolog.Ctx(ctx)

    logger.Info().
        Str("order_id", order.ID).
        Msg("processing order")

    if err := s.validate(ctx, order); err != nil {
        logger.Error().
            Err(err).
            Str("order_id", order.ID).
            Msg("order validation failed")
        return err
    }
    return nil
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import "log/slog"

// Basic usage (defaults to text output):
slog.Info("order processed", "order_id", "ORD-123", "latency_ms", 42)

// JSON handler:
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelInfo,
}))
slog.SetDefault(logger)

// With context (propagates attributes):
ctx := context.Background()
logger.InfoContext(ctx, "order processed",
    slog.String("order_id", "ORD-123"),
    slog.Int("latency_ms", 42),
)

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:

  1. Every log line has a static message
  2. Every log line has the relevant IDs (request, user, entity)
  3. Errors are structured fields with the full error value
  4. Context-scoped fields are automatically propagated
  5. Log levels mean what they say and are acted on accordingly
  6. 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.