Structured logging in Go with log/slog

Configure Go slog handlers, typed attributes, request context, dynamic levels, and error logging, then validate JSON fields before sending them to Guance.

Best practices
Go structured logging with JSON handlers and request context

Go's log/slog package, introduced in Go 1.21, provides structured logging in the standard library. Application code records a message and attributes; a handler decides how to format and write the event. That separation lets a service use readable text during development and JSON in production without changing every logging call.

This guide covers the default logger, built-in handlers, reusable context, runtime configuration, and collection in Guance. The examples assume Go 1.21 or later. Unless a block includes a package declaration, it is a fragment to place inside the surrounding application with the indicated standard-library imports.

Start with the default logger

Package-level functions use the default logger:

package main

import "log/slog"

func main() {
    slog.Debug("Debug details") // Suppressed at the default INFO threshold.
    slog.Info("Service started", "port", 8080)
    slog.Warn("Configuration missing; using default")
    slog.Error("Database connection failed", "err", "connection refused")
}

The output resembles the following; timestamps depend on when and where the program runs:

2026/08/25 14:30:22 INFO Service started port=8080
2026/08/25 14:30:22 WARN Configuration missing; using default
2026/08/25 14:30:22 ERROR Database connection failed err="connection refused"

The predefined levels are Debug=-4, Info=0, Warn=4, and Error=8. The gaps leave room for application-defined levels. For example, level 2 appears as INFO+2 unless a handler customizes its representation.

The default logger is convenient for getting started. An explicit handler gives the application control over its output destination, format, threshold, and attributes. See the slog package reference.

Choose a handler for the destination

A logger records events through the slog API. Its handler formats those events and writes them to an io.Writer.

Use TextHandler for readable key-value output

With log/slog and os imported:

logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
logger.Info("User signed in", "user", "zhangsan", "ip", "10.0.0.8")

TextHandler produces logfmt-style key-value pairs. A representative record is:

time=2026-08-25T14:31:05.123+08:00 level=INFO msg="User signed in" user=zhangsan ip=10.0.0.8

This is convenient for reading locally while keeping fields visible.

Use JSONHandler for structured collection

logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("User signed in", "user", "zhangsan", "ip", "10.0.0.8")

The same event becomes a JSON object on one line:

{"time":"2026-08-25T14:31:05.123456+08:00","level":"INFO","msg":"User signed in","user":"zhangsan","ip":"10.0.0.8"}

Explicit fields make collection easier, but the backend still needs to interpret them correctly. When sending these logs to Guance, verify the timestamp, message, severity, and service fields with a sample before enabling production collection. The log Pipeline guide describes the processing workflow.

Add attributes and reuse context

Prefer typed attributes where they improve clarity

The key-value form is concise, but typed attributes make the intended value type explicit. This example also requires the time package:

logger.Info("Order created",
    slog.String("order_id", "A-1024"),
    slog.Int("amount", 9900),
    slog.Duration("elapsed", 35*time.Millisecond),
)

Define units in the schema. Here, amount is only a number until the application establishes whether it represents cents or another unit. Similarly, the built-in JSON handler serializes a time.Duration as an integer number of nanoseconds. If downstream queries expect milliseconds, convert deliberately or customize the handler; do not infer units from the field name alone.

An unmatched key-value argument can produce a !BADKEY field. The compiler does not reject every malformed call, though go vet can identify common mistakes. Typed slog.Attr constructors avoid the alternating-key/value mistake. LogAttrs is available when a call should accept attributes directly:

logger.LogAttrs(context.Background(), slog.LevelInfo, "Order created",
    slog.String("order_id", "A-1024"),
    slog.Int("amount", 9900),
)

This fragment also imports context. It does not establish a universal performance advantage; benchmark the relevant handler and output path if logging is significant to the service's workload.

Attach shared fields with With

A request often generates several events that share the same identifiers. Derive a logger once rather than adding the same values to every call:

rid := "req-1024"
reqLogger := logger.With(
    slog.String("request_id", rid),
    slog.String("user", "zhangsan"),
)
reqLogger.Info("Processing order")
reqLogger.Info("Order processing completed")

Both events include request_id and user. The derived logger keeps this context attached to subsequent calls.

Group related fields

logger.Info("Payment callback received",
    slog.Group("payload",
        slog.String("order_id", "A-1024"),
        slog.Int("amount", 9900),
    ),
)

JSONHandler writes payload as a nested object. TextHandler uses a dotted representation for grouped keys. Check that the collector preserves the structure expected by downstream queries.

Configure production behavior with HandlerOptions

The following complete program enables source locations, normalizes the built-in level field, and uses a level that can be changed at runtime:

package main

import (
    "log/slog"
    "os"
    "strings"
)

func main() {
    level := new(slog.LevelVar) // Its zero value is INFO.
    opts := &slog.HandlerOptions{
        Level:     level,
        AddSource: true,
        ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
            // Normalize top-level slog.Level values without rewriting user data.
            if len(groups) == 0 && a.Key == slog.LevelKey {
                if severity, ok := a.Value.Any().(slog.Level); ok {
                    a.Value = slog.StringValue(strings.ToLower(severity.String()))
                }
            }
            return a
        },
    }
    logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))
    slog.SetDefault(logger)

    slog.Info("Service started", slog.Int("port", 8080))
    level.Set(slog.LevelDebug)
    slog.Debug("Temporary debug logging enabled")
    level.Set(slog.LevelInfo)
}

The options address different concerns:

  • Level sets the minimum severity. Use a pointer to slog.LevelVar to change it dynamically; its operations are safe for concurrent use. If an administrative endpoint changes this value, protect that endpoint and define when a temporary debug setting expires.
  • AddSource includes the source location. For JSON output, source is an object rather than a single file:line string. Collecting that information has a cost, so measure it where throughput matters.
  • ReplaceAttr can rename, transform, or remove attributes. It can normalize fields such as time or level to match an agreed backend schema. The group check avoids changing a nested level attribute, and the type check leaves an ordinary string or numeric attribute named level unchanged. Reserve that key for severity in the application schema to avoid duplicate JSON keys.

SetDefault updates the logger used by package-level slog functions. It also redirects output from the standard log package's default logger through the new handler. A separate NewLogLogger is useful when a library explicitly expects a *log.Logger; it is not required just to redirect existing package-level log.Print calls.

Record errors as values

An error can be attached to a structured event alongside the operation's identifiers:

if err := processPayment(order); err != nil {
    logger.Error("Payment processing failed",
        slog.String("order_id", order.ID),
        slog.Any("error", err),
    )
}

This is an application fragment: processPayment and order belong to the surrounding program. The built-in JSON handler normally renders a directly supplied error attribute as its error string.

If a custom error type needs structured fields such as a code or category, it can implement slog.LogValuer. Define deliberately which information is safe to expose. Error strings can contain sensitive details, just as ordinary messages can.

Slog does not capture a stack trace automatically. runtime/debug.Stack() captures the stack at the point where it is called, which may be the logging site rather than the original failure site. If the original call stack matters, use an error-handling approach that captures and preserves it at the appropriate boundary.

Validate collection in Guance

Choose container standard output or a host file path using the DataKit logging guide, and configure the service identity.

Then send a small set of representative JSON events. Confirm that:

  1. time becomes the intended event timestamp, including its offset.
  2. level maps to the severity field used by the destination, such as status.
  3. msg remains available as the human-readable message.
  4. Numeric fields retain their types and documented units.
  5. An ERROR event can be found with the expected service and severity filters.

Use With for a request identifier that the application already owns. For trace correlation, obtain the actual trace ID from the active tracing context and put it in the expected field. Passing a context to InfoContext does not, by itself, make the built-in handler inject tracing identifiers. The handler or application integration must do that work.

The collection test should include a real traced request so that opening the log leads to the expected trace. Matching field names alone is insufficient if identifiers were generated independently or changed during processing.

Common questions

Can a project on an older Go version use slog?

The standard-library package requires Go 1.21 or later. The earlier experimental implementation lived in golang.org/x/exp/slog, but an old runtime needs an explicitly compatible module revision. Do not assume that today's module version supports an old compiler or that every experimental revision has exactly the same API. Upgrading to a supported Go release and using log/slog is the simpler path for a new project.

Is slog faster than Zap or Zerolog?

There is no useful universal ranking without a workload. Attribute construction, formatting, enabled levels, output destination, and contention can all affect the result. Measure allocations and latency with the actual handler if logging appears in a profile. Adapters can connect the slog API to other backends, but verify their field, level, and context behavior before swapping them in.

Does slog rotate files?

No. Its built-in handlers write to an io.Writer; they do not manage file rotation. Containers can write to standard output and rely on the runtime's configured rotation policy. A host deployment can use a rotation-aware writer or coordinate file reopening with an external rotation system.

Do not assume copytruncate is lossless. The interval between copying and truncating introduces a race with ongoing writes. Choose the rotation mechanism according to the application's delivery requirements and test it together with the collector.

How can an application with many log.Printf calls migrate gradually?

Install a handler and call slog.SetDefault(logger) to route the standard log package's default output through it. Existing message text remains unstructured; new code can add real attributes with the slog API.

When a dependency accepts its own *log.Logger, create a bridge and pass that instance explicitly:

legacyLogger := slog.NewLogLogger(logger.Handler(), slog.LevelInfo)
legacyLogger.Printf("Connected to dependency %s", "payments")

This makes migration incremental. It does not turn values embedded in old format strings into separate fields automatically.

Related guides

Get a tailored plan

Contact us

Join the community

Scan with WeChat
to join the community

Try Guance

Start online and pay only for what you use.

Get started

Choose a Guance plan

Code hosting