Skip to content
AgileLogics
6 min read

Structured Logging with slog

Prerequisites: Testing with go test

The log/slog package (Go 1.21+) writes structured logs: each entry has a level, a message, and key-value attributes. Structured logs are easy for log tools to search and filter — status=500 instead of a sentence you have to parse.

  • A slog.Logger is what you call: logger.Info("msg", "key", value).
  • A handler decides the output format. The standard library ships slog.NewTextHandler (key=value pairs) and slog.NewJSONHandler.
  • Levels: Debug, Info, Warn, Error. The handler's Level option drops anything below it (the default is Info).
  • logger.With(...) returns a logger that adds the same attributes to every entry — handy for a request ID.

Syntax

logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

logger.Info("user signed in", "user_id", 42, "method", "password")
logger.Error("payment failed", slog.String("order", "A-17"), slog.Int("attempt", 3))

reqLog := logger.With("request_id", "abc123")
slog.SetDefault(logger) // package-level slog.Info etc. now use this logger

Example

Real logs include a time attribute. This example removes it with ReplaceAttr so the output is repeatable.

package main

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

func main() {
    opts := &slog.HandlerOptions{
        Level: slog.LevelDebug,
        ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
            if a.Key == slog.TimeKey && len(groups) == 0 {
                return slog.Attr{} // an empty Attr is dropped
            }
            return a
        },
    }

    text := slog.New(slog.NewTextHandler(os.Stdout, opts))
    text.Info("server started", "port", 8080, "env", "dev")
    text.Debug("cache warmed", "entries", 42)

    jsonLog := slog.New(slog.NewJSONHandler(os.Stdout, opts))
    reqLog := jsonLog.With("request_id", "abc123")
    reqLog.Warn("slow request",
        slog.Duration("elapsed", 1500*time.Millisecond),
        slog.Int("status", 200))
    reqLog.Error("payment failed",
        slog.Group("user", slog.Int("id", 7), slog.String("plan", "pro")))

    // A handler at Warn level drops Info and Debug entries.
    quiet := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
        Level:       slog.LevelWarn,
        ReplaceAttr: opts.ReplaceAttr,
    }))
    quiet.Info("you will not see this")
    quiet.Warn("disk almost full", "free_gb", 2)
}

Expected output

level=INFO msg="server started" port=8080 env=dev
level=DEBUG msg="cache warmed" entries=42
{"level":"WARN","msg":"slow request","request_id":"abc123","elapsed":1500000000,"status":200}
{"level":"ERROR","msg":"payment failed","request_id":"abc123","user":{"id":7,"plan":"pro"}}
level=WARN msg="disk almost full" free_gb=2

The JSON handler writes durations as nanoseconds; the text handler would print elapsed=1.5s.

Common mistakes

  • Passing an odd number of arguments (logger.Info("msg", "user_id")). The unmatched value is logged under the key !BADKEY. go vet catches this, and the typed helpers (slog.Int, slog.String, ...) avoid it.
  • Building messages with fmt.Sprintf("user %d failed", id). Keep the message constant and put the variable parts in attributes, so logs can be filtered by them.
  • Logging secrets such as passwords, tokens or full card numbers.
  • Leaving the level at Debug in production and flooding your log storage. Make the level configurable (for example with a slog.LevelVar).

Practice

Create a JSON logger that only logs Warn and above and omits the time attribute. Log an Info entry (which should not appear) and a Warn entry "retrying" with attributes attempt=2 and service="billing".

Show solution
package main

import (
    "log/slog"
    "os"
)

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: slog.LevelWarn,
        ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
            if a.Key == slog.TimeKey && len(groups) == 0 {
                return slog.Attr{}
            }
            return a
        },
    }))

    logger.Info("starting up") // below Warn: dropped
    logger.Warn("retrying", "attempt", 2, "service", "billing")
    // {"level":"WARN","msg":"retrying","attempt":2,"service":"billing"}
}