70. Structured logging with `log/slog`

📖 Reading · 15 min
💡 Most code boxes below are live — edit one and hit Run. Boxes without a Run button are reference-only (they can't run in your browser).

The last lesson logged with fmt.Printf. That's fine for a lesson box and bad for a service, because a line like

GET /items -> 404 in 31ms for user ada

can only be searched with a regex. When you have four million of them and need "every 5xx for user ada in the last hour", you want fields, not prose.

Go 1.21 added log/slog to the standard library for exactly this.

Levels and key–value pairs

slog has a package-level logger you can call immediately:

package main

import "log/slog"

func main() {
    slog.Info("server starting", "port", 8080, "env", "dev")
    slog.Warn("slow request", "path", "/items", "ms", 412)
}
2026/08/12 02:21:19 INFO server starting port=8080 env=dev
2026/08/12 02:21:19 WARN slow request path=/items ms=412

(Your timestamp will differ — which is exactly why that box isn't runnable here. Every box below builds a logger with the timestamp switched off so the output is stable enough to print in a lesson.)

The call shape is one message plus alternating key, value, key, value. The message stays constant and the varying parts become fields, which is the whole idea: msg="slow request" path=/items groups every slow request together no matter which path was slow.

Four levels, in order: Debug, Info, Warn, Error.

Choosing a handler

A Logger is a thin front end; a handler decides the format and destination. Two ship with the standard library:

package main

import (
    "log/slog"
    "os"
)

// dropTime removes the timestamp so these lesson boxes print the same thing
// every run. Real services keep it.
func dropTime(groups []string, a slog.Attr) slog.Attr {
    if a.Key == slog.TimeKey {
        return slog.Attr{}
    }
    return a
}

func main() {
    log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ReplaceAttr: dropTime}))

    log.Info("server starting", "port", 8080, "env", "dev")
    log.Warn("slow request", "path", "/items", "ms", 412)
    log.Error("upstream failed", "service", "billing", "attempt", 3)
}
level=INFO msg="server starting" port=8080 env=dev
level=WARN msg="slow request" path=/items ms=412
level=ERROR msg="upstream failed" service=billing attempt=3

TextHandler writes key=value pairs — readable in a terminal, still machine-parseable. Values needing quotes get them automatically.

package main

import (
    "log/slog"
    "os"
)

func dropTime(groups []string, a slog.Attr) slog.Attr {
    if a.Key == slog.TimeKey {
        return slog.Attr{}
    }
    return a
}

func main() {
    log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ReplaceAttr: dropTime}))

    log.Info("request served", "method", "GET", "path", "/items", "status", 200)
    log.Error("query failed", "table", "items", "err", "connection refused")
}
{"level":"INFO","msg":"request served","method":"GET","path":"/items","status":200}
{"level":"ERROR","msg":"query failed","table":"items","err":"connection refused"}

JSONHandler is what you deploy. Every log aggregator ingests JSON lines without configuration, and status arrives as the number 200, not the string "200" — so you can query for status >= 500 and get arithmetic rather than string matching.

The usual arrangement: TextHandler locally, JSONHandler in production, and one if at startup to choose. slog.SetDefault(log) makes your configured logger the one the package-level slog.Info uses, so libraries logging through slog land in the same stream.

ReplaceAttr is the general escape hatch, and it isn't only for lesson boxes — it's how you redact. Return slog.Attr{} to drop an attribute entirely, or rewrite the value in place to mask a password or an access token before it ever reaches disk.

Levels filter

package main

import (
    "log/slog"
    "os"
)

func dropTime(groups []string, a slog.Attr) slog.Attr {
    if a.Key == slog.TimeKey {
        return slog.Attr{}
    }
    return a
}

func main() {
    opts := &slog.HandlerOptions{Level: slog.LevelInfo, ReplaceAttr: dropTime}
    log := slog.New(slog.NewTextHandler(os.Stdout, opts))

    log.Debug("this is filtered out")
    log.Info("this gets through")

    verbose := slog.New(slog.NewTextHandler(os.Stdout,
        &slog.HandlerOptions{Level: slog.LevelDebug, ReplaceAttr: dropTime}))
    verbose.Debug("now debug shows", "detail", "everything")
}
level=INFO msg="this gets through"
level=DEBUG msg="now debug shows" detail=everything

HandlerOptions.Level sets the floor; anything below it is discarded. The default floor is Info, which is why the first Debug produced nothing.

Filtering happens before the arguments are formatted, so a Debug call in a hot path costs almost nothing when debug is off. Leave the debug logging in.

Request-scoped loggers

With returns a logger that carries fields on every subsequent call:

package main

import (
    "log/slog"
    "os"
)

func dropTime(groups []string, a slog.Attr) slog.Attr {
    if a.Key == slog.TimeKey {
        return slog.Attr{}
    }
    return a
}

func main() {
    base := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ReplaceAttr: dropTime}))

    reqLog := base.With("request_id", "abc123", "user", "ada")
    reqLog.Info("handling request")
    reqLog.Info("finished", slog.Group("timing", "db_ms", 12, "total_ms", 31))
}
level=INFO msg="handling request" request_id=abc123 user=ada
level=INFO msg=finished request_id=abc123 user=ada timing.db_ms=12 timing.total_ms=31

This is the single most useful thing in the package. Build a logger once per request with the request's ID, hand it down, and every line from that request is findable with one query — no threading an ID through six function signatures and forgetting it in the seventh.

slog.Group nests related fields; they come out dotted in text and as a nested object in JSON.

Logging middleware, properly

Now the last lesson's middleware with a real logger:

package main

import (
    "log/slog"
    "net/http"
    "net/http/httptest"
    "os"
)

func dropTime(groups []string, a slog.Attr) slog.Attr {
    if a.Key == slog.TimeKey {
        return slog.Attr{}
    }
    return a
}

type statusWriter struct {
    http.ResponseWriter
    status int
}

func (s *statusWriter) WriteHeader(code int) {
    s.status = code
    s.ResponseWriter.WriteHeader(code)
}

func requestLogger(log *slog.Logger, next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
        next.ServeHTTP(sw, r)
        log.Info("request",
            "method", r.Method,
            "path", r.URL.Path,
            "status", sw.status,
        )
    })
}

func main() {
    log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ReplaceAttr: dropTime}))

    mux := http.NewServeMux()
    mux.HandleFunc("GET /items", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("[]"))
    })
    mux.HandleFunc("GET /boom", func(w http.ResponseWriter, r *http.Request) {
        http.Error(w, "nope", http.StatusInternalServerError)
    })

    h := requestLogger(log, mux)
    for _, p := range []string{"/items", "/boom", "/missing"} {
        h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", p, nil))
    }
}
{"level":"INFO","msg":"request","method":"GET","path":"/items","status":200}
{"level":"INFO","msg":"request","method":"GET","path":"/boom","status":500}
{"level":"INFO","msg":"request","method":"GET","path":"/missing","status":404}

Note the third line: the mux's built-in 404 was logged too, because the middleware wraps the mux rather than any individual handler.

requestLogger takes the logger as a parameter instead of reaching for a global. That's the dependency-injection habit from module 5 — it means a test can pass a logger writing to a bytes.Buffer and assert on what was logged.

A real one would add the duration (time.Since(start)), a request ID, and r.RemoteAddr. Everything else stays exactly this shape.

What not to log

  • Never log secrets. Passwords, tokens, keys, card numbers. Logs get copied to places the database never goes. ReplaceAttr can enforce this centrally.
  • Log the error, not a paraphrase. "err", err keeps the wrapped chain from module 7; "err", "something failed" throws it away.
  • One line per event. Multi-line log entries defeat every line-oriented tool between you and the answer.

Your turn

Build a JSON logger, derive a request-scoped logger carrying order_id 1001, then log an Info "order received" with items 3, and an Error "payment declined" with reason insufficient_funds.

dropTime is given — pass it so the output is stable.

package main

import (
    "log/slog"
    "os"
)

func dropTime(groups []string, a slog.Attr) slog.Attr {
    if a.Key == slog.TimeKey {
        return slog.Attr{}
    }
    return a
}

func main() {
    // build a JSON logger writing to os.Stdout, using dropTime
    // derive a logger carrying order_id=1001
    // Info  "order received"   with items=3
    // Error "payment declined" with reason=insufficient_funds
}
package main

import (
    "log/slog"
    "os"
)

func dropTime(groups []string, a slog.Attr) slog.Attr {
    if a.Key == slog.TimeKey {
        return slog.Attr{}
    }
    return a
}

func main() {
    base := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ReplaceAttr: dropTime}))

    orderLog := base.With("order_id", 1001)
    orderLog.Info("order received", "items", 3)
    orderLog.Error("payment declined", "reason", "insufficient_funds")
}

Next: the server itself — and how to stop one without dropping requests.