Go Programming

Structured Logging in Go with log/slog: A Practical Guide

Replace fmt-style logging with Go's built-in log/slog package: JSON handlers, levels, context-aware loggers, grouped attributes and request-scoped fields, with runnable examples.

8 min read 1584 words
Structured Logging in Go with log/slog: A Practical Guide

For years, every Go service I wrote started with the same decision: which logging library this time? Since Go 1.21 that decision has a boring, excellent default — log/slog ships with the standard library, speaks JSON out of the box, and needs no dependency at all. This post is the tour I wish I had when I migrated my first service to it.

Why Structured Logs Beat Formatted Strings

Here is a line the old log package might produce:

2026/08/04 10:15:02 user 42 checkout failed after 1.2s: payment declined

It reads fine. Now try answering “how many checkouts failed for users on the EU cluster last Tuesday between 14:00 and 15:00?” You are writing a regular expression.

The same event as structured data:

{"time":"2026-08-04T10:15:02.113Z","level":"ERROR","msg":"checkout failed",
 "user_id":42,"duration_ms":1204,"reason":"payment declined","cluster":"eu"}

Now it is a query. Every log aggregator — Loki, Elasticsearch, CloudWatch, BigQuery — indexes those fields directly. The point of structured logging is not prettier output; it is that your logs become a queryable dataset.

The Smallest Useful Setup

package main

import (
    "log/slog"
    "os"
)

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

    // Make it the package-level default so slog.Info et al. use it too.
    slog.SetDefault(logger)

    slog.Info("service started", "port", 8080, "env", "production")
}

Output:

{"time":"2026-08-04T10:15:02.09Z","level":"INFO","msg":"service started","port":8080,"env":"production"}

The variadic arguments are alternating keys and values. If you prefer something the compiler can check, use typed attributes instead:

slog.Info("service started",
    slog.Int("port", 8080),
    slog.String("env", "production"),
)

Both forms are fine, and you can mix them. The typed form costs a little more typing and saves you from the classic odd-number-of-arguments bug, where a stray value ends up under the key !BADKEY.

For local development, swap the handler and keep everything else:

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

Levels, and Changing Them Without a Redeploy

slog has four built-in levels — Debug (-4), Info (0), Warn (4), Error (8) — as plain integers, so you can define your own in between if you really need to.

The more useful trick is a LevelVar, which lets you change the level at runtime:

var logLevel = new(slog.LevelVar) // defaults to Info

func main() {
    slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: logLevel,
    })))

    // Flip to debug on demand — from a signal handler, an admin endpoint,
    // or a config watcher.
    http.HandleFunc("/debug/level", func(w http.ResponseWriter, r *http.Request) {
        var l slog.Level
        if err := l.UnmarshalText([]byte(r.FormValue("level"))); err != nil {
            http.Error(w, "bad level", http.StatusBadRequest)
            return
        }
        logLevel.Set(l)
        slog.Warn("log level changed", "level", l)
    })
}

Being able to turn on debug logging for two minutes on a misbehaving pod, without a deploy, has saved me more time than any other logging feature. Put that endpoint behind authentication — the router access permission patterns I wrote about earlier work well for exactly this kind of internal route.

Attaching Context With With

logger.With returns a new logger that carries the given attributes on every subsequent call. This is how you stop repeating yourself:

// Once, at construction:
type Worker struct {
    log *slog.Logger
}

func NewWorker(id int, queue string) *Worker {
    return &Worker{
        log: slog.Default().With("worker_id", id, "queue", queue),
    }
}

func (w *Worker) process(job Job) {
    // Every line from this worker carries worker_id and queue automatically.
    w.log.Info("job started", "job_id", job.ID)
    // ...
    w.log.Info("job finished", "job_id", job.ID, "duration_ms", 42)
}

With does the attribute formatting work once, at call time, rather than on every log line — so a long-lived logger built with With is cheaper than passing the same attributes repeatedly.

Use WithGroup when you want to namespace a block of fields:

log := slog.Default().WithGroup("http")
log.Info("request", "method", "GET", "path", "/users")
// {"level":"INFO","msg":"request","http":{"method":"GET","path":"/users"}}

Or group inline, for one call:

slog.Info("upstream call",
    slog.String("service", "billing"),
    slog.Group("response",
        slog.Int("status", 502),
        slog.Duration("latency", 1200*time.Millisecond),
    ),
)

Request-Scoped Logging

The pattern that pays off most in a web service: put a logger carrying the request ID into the request context, then let every layer below pull it out. If you have not used context.Context for this kind of request-scoped data before, understanding Golang context covers the rules — including why the key must be an unexported type.

package logging

import (
    "context"
    "log/slog"
)

type ctxKey struct{}

// Into returns a copy of ctx carrying logger.
func Into(ctx context.Context, logger *slog.Logger) context.Context {
    return context.WithValue(ctx, ctxKey{}, logger)
}

// From returns the logger stored in ctx, or the default logger.
func From(ctx context.Context) *slog.Logger {
    if l, ok := ctx.Value(ctxKey{}).(*slog.Logger); ok {
        return l
    }
    return slog.Default()
}

The middleware that fills it in — a close cousin of the handler-wrapping in my Go middleware example:

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()
        }

        log := slog.Default().With(
            "request_id", requestID,
            "method", r.Method,
            "path", r.URL.Path,
        )

        start := time.Now()
        rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}

        next.ServeHTTP(rec, r.WithContext(logging.Into(r.Context(), log)))

        log.Info("request completed",
            "status", rec.status,
            "duration_ms", time.Since(start).Milliseconds(),
        )
    })
}

// statusRecorder remembers the status code written by the handler.
type statusRecorder struct {
    http.ResponseWriter
    status int
}

func (r *statusRecorder) WriteHeader(code int) {
    r.status = code
    r.ResponseWriter.WriteHeader(code)
}

Now any function that already takes a context.Context can log with full request context and no extra parameters:

func (s *Store) User(ctx context.Context, id int64) (*User, error) {
    logging.From(ctx).Debug("loading user", "user_id", id)
    // ...
}

Every line from that request — across every layer — shares a request_id. Tracing one user’s bad afternoon becomes a single filter in your log viewer.

Redacting Secrets With ReplaceAttr

HandlerOptions.ReplaceAttr runs on every attribute before it is written. It is the right place to enforce policy centrally instead of trusting each call site.

var sensitive = map[string]bool{
    "password": true, "token": true, "authorization": true,
    "api_key": true, "secret": true, "set-cookie": true,
}

handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level:     slog.LevelInfo,
    AddSource: true, // include file:line — useful for errors
    ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
        if sensitive[strings.ToLower(a.Key)] {
            return slog.String(a.Key, "[REDACTED]")
        }
        // Rename "time" to "timestamp" to match the rest of our pipeline.
        if a.Key == slog.TimeKey && len(groups) == 0 {
            a.Key = "timestamp"
        }
        return a
    },
})

For types you control, implementing LogValuer is even better — the value redacts itself wherever it is logged:

type Password string

// LogValue implements slog.LogValuer so a Password never reaches a log sink.
func (Password) LogValue() slog.Value {
    return slog.StringValue("[REDACTED]")
}

slog.Info("login attempt", "user", "alex", "password", Password("hunter2"))
// {"level":"INFO","msg":"login attempt","user":"alex","password":"[REDACTED]"}

LogValuer is also how you log a struct compactly. Give your User type a LogValue that returns a group with just the ID and role, and you never accidentally dump an entire record — with its email address and hashed password — into a log line.

Logging Errors Properly

Log the error value, not a formatted string, and let the handler decide how to render it:

if err != nil {
    slog.Error("checkout failed", "err", err, "user_id", userID)
    return fmt.Errorf("checkout: %w", err)
}

Two habits worth keeping:

  • Log once, at the boundary. If you log here and return the error, every caller up the stack logs it again. That is the same rule I covered in error handling in Go, and slog does not change it.
  • Do not log context.Canceled as an error. A user closing a tab is not an incident.
switch {
case errors.Is(err, context.Canceled):
    slog.Debug("client disconnected", "path", r.URL.Path)
case err != nil:
    slog.Error("request failed", "err", err, "path", r.URL.Path)
}

Bridging Libraries That Use the Old log Package

Dependencies still writing to the standard log package do not have to break your JSON output:

// Route everything from the standard logger through slog at Info level.
slog.SetLogLoggerLevel(slog.LevelInfo)

// Or hand a specific component its own *log.Logger backed by slog.
srv := &http.Server{
    Addr:     ":8080",
    Handler:  mux,
    ErrorLog: slog.NewLogLogger(slog.Default().Handler(), slog.LevelError),
}

That second line is worth adding to every server you write — http.Server logs connection errors through ErrorLog, and by default they land on stderr as unstructured text that your aggregator will not parse.

Performance Notes

slog is designed so the common path allocates very little, but a few habits matter:

  • Guard genuinely expensive debug work behind Enabled:

    if slog.Default().Enabled(ctx, slog.LevelDebug) {
        slog.Debug("payload", "body", expensiveDump(req))
    }
    
  • Prefer LogAttrs in hot paths — it takes typed attributes and skips the any boxing:

    slog.LogAttrs(ctx, slog.LevelInfo, "cache hit",
        slog.String("key", key),
        slog.Int("size", len(val)),
    )
    
  • Build the per-request logger once with With, not per log line.

None of this matters at ten requests per second. All of it matters in a hot loop — the same way caching with Ristretto only pays off once you are actually calling the expensive thing often.

Checklist

  • One handler, configured once in main, installed with slog.SetDefault.
  • JSON in production, text locally.
  • A LevelVar so you can raise verbosity without a redeploy.
  • A request-scoped logger in the context, carrying the request ID.
  • ReplaceAttr or LogValuer for anything secret.
  • Errors logged once, at the boundary, as values.
  • http.Server.ErrorLog wired through slog.

Conclusion

log/slog removed the last dependency I used to add reflexively to every new Go service. It is fast enough, it is in the standard library, and its handler interface means you can change output format or destination without touching a single call site. If you are still logging with fmt.Sprintf, the migration is mostly mechanical — and the first time you filter a week of logs by request_id, you will not want to go back.

It pays off especially well for anything token-billed, where a handful of numeric fields per call is the only record of what a feature costs — calling an LLM from Go covers which ones to log.