Back to blog
Aug 08, 2026
5 min read

Structured Logging in Go

Why unstructured logs become a liability at scale, how to use slog effectively, and the practical conventions that make logs actually useful in production.

I’ve debugged enough production incidents to have a strong opinion on logging: if your logs are just free-form strings, you’re making future-you work harder than necessary.

When something breaks at 2 a.m., you want to filter by request_id, user_id, or error_code without writing creative regexes. That’s the bar.


The problem with log.Printf

This style still shows up everywhere:

log.Printf("failed to process order %s for user %s: %v", orderID, userID, err)

It works fine until you need to answer questions like:

  • How many times did this error happen in the last 15 minutes?
  • Which users were affected?
  • Was it correlated with a specific endpoint or region?

Then those strings become a parsing problem. Structured logs turn the same information into fields you can query.


Enter log/slog

Go 1.21 added log/slog to the standard library. It’s good enough that I no longer reach for third-party loggers in most new services.

Basic usage:

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

logger.Info("order processed",
	"order_id", orderID,
	"user_id", userID,
	"duration_ms", duration.Milliseconds(),
)

Output looks like:

{"time":"2026-08-08T14:30:00Z","level":"INFO","msg":"order processed","order_id":"ord_123","user_id":"u_456","duration_ms":42}

That is immediately more useful in any log backend that understands JSON.


Levels and when to use them

I keep it simple:

LevelWhen I use it
DebugDetailed diagnostics, usually off in production
InfoNormal, expected events that are still useful
WarnSomething unexpected but the system handled it
ErrorAn operation failed and someone should look

A common mistake is logging every successful request at Info in high-traffic services. That gets expensive fast and drowns the signal. Prefer metrics for high-cardinality volume, and keep logs for things that need context.


Context and request-scoped fields

The most valuable pattern is attaching request-scoped data once and having it appear on every subsequent log line.

func middleware(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 := context.WithValue(r.Context(), loggerKey, logger)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

Then deeper in the stack:

func process(ctx context.Context, orderID string) error {
	logger := loggerFromContext(ctx)

	logger.Info("processing order", "order_id", orderID)
	// ...
	if err != nil {
		logger.Error("processing failed", "order_id", orderID, "err", err)
		return err
	}
	return nil
}

Now every log line related to that request carries the same request_id. When an incident hits, you grab one ID and reconstruct the whole story.


Logging errors properly

Prefer this:

logger.Error("failed to charge card", "order_id", orderID, "err", err)

over concatenating the error into the message string. Keeping err as a field makes it easier to group and search.

Also, don’t log and return the same error at every layer. That produces the classic wall of duplicate error lines. Log at the boundary where you still have useful context, or at the top level, but not everywhere in between.


What not to put in logs

A short list of things that have caused problems:

  • Tokens, passwords, session cookies
  • Full credit card numbers / sensitive PII (unless you have a clear retention and redaction policy)
  • Enormous payloads (request bodies, big structs dumped with %+v)
  • High-cardinality values that turn your log system into an expensive metrics store

If you need the payload for debugging, sample it or log a hash / truncated version.


slog handlers and production setup

For most services I use the JSON handler in production and the text handler locally:

var handler slog.Handler
if os.Getenv("ENV") == "production" {
	handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
		Level: slog.LevelInfo,
	})
} else {
	handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
		Level: slog.LevelDebug,
	})
}

slog.SetDefault(slog.New(handler))

You can also add custom handlers for redaction, sampling, or shipping to a specific backend, but start simple. A lot of complexity in logging libraries is unnecessary once you have structured fields and a decent log platform.


Correlation with metrics and traces

Logs, metrics, and traces answer different questions. I try not to force logs to do the job of metrics.

  • Metrics → how often / how long / how much
  • Traces → the path of a single request across services
  • Logs → the detailed context around a specific event

If you already have trace IDs, put them in the log fields. That single decision makes debugging distributed systems significantly less painful.


Practical conventions I stick to

  • Use snake_case for field names (order_id, not orderId)
  • Be consistent across services (same field names for the same concepts)
  • Prefer a small, stable set of common fields over inventing new ones every time
  • Keep messages short and let the fields carry the detail
  • Log the outcome of important side effects (payments, emails, state transitions), not every internal step

Closing

Structured logging is one of those investments that feels optional until the first serious incident. After that it feels non-negotiable.

You don’t need a sophisticated logging framework. You need consistent fields, request correlation, sensible levels, and enough discipline not to log noise or secrets. slog is more than enough to get there.