fmt, log, and log/slog

Updated

September 8, 2026

fmt, log, and log/slog

Overview

Three packages cover output:

Package Role
fmt Formatting values for humans and strings
log Legacy line logger (still fine for tiny tools)
log/slog Structured logging (Go 1.21+ default for services)

Rule of thumb: libraries return errors; programs log. Prefer slog for anything that runs as a service or daemon.

fmt Essentials

fmt.Print("a", 1)           // a1  (no spaces between args of different kinds as you'd expect — use Printf)
fmt.Println("hello", 42)    // hello 42\n
s := fmt.Sprintf("%s=%d", "n", 3)
fmt.Fprintf(w, "status=%v\n", err)

Verbs you actually need

Verb Meaning
%v Default format
%+v Struct with field names
%#v Go-syntax representation
%T Type
%s / %q String / quoted
%d / %x Decimal / hex
%f / %g Float
%w Wrap error (fmt.Errorf)
%+v on errors Implementation-defined detail
err := fmt.Errorf("open %s: %w", path, err)

Strings and builders

For hot paths assembling many pieces, prefer strings.Builder or fmt.Fprintf into a builder — not repeated + in a loop.

log (legacy)

import "log"

log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Println("starting")
log.Printf("port=%d", port)
log.Fatal(err) // log + os.Exit(1)

Fine for CLIs and examples. Lacks structured fields and levels as first-class data.

log/slog (preferred)

import "log/slog"

slog.Info("server start", "addr", addr)
slog.Error("query failed", "err", err, "query", q)

Handlers

// Human text (dev)
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
    Level: slog.LevelDebug,
})))

// JSON (prod)
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelInfo,
})))

Levels and groups

slog.Debug("cache hit", "key", key)
slog.Warn("retrying", "attempt", n)

logger := slog.Default().With("service", "billing")
logger.Info("charge", "user_id", id, "cents", 199)

Context-aware logging

func handle(ctx context.Context, w http.ResponseWriter, r *http.Request) {
    log := slog.Default()
    if rid, ok := ctx.Value(requestIDKey).(string); ok {
        log = log.With("request_id", rid)
    }
    log.Info("request", "path", r.URL.Path, "method", r.Method)
}

For production correlation patterns, see Structured logging.

Anti-patterns

  1. Logging and returning the same error without a policy — pick one place that owns the user-visible message.
  2. Logging secrets — tokens, passwords, full auth headers.
  3. fmt.Sprintf then log the string — lose structure; pass key-value pairs.
  4. log.Fatal deep in libraries — exits the process; return error instead.

Summary

Situation Use
Build a message string fmt.Sprintf / Errorf
One-off script noise log or fmt
Service / long-running tool log/slog
Wrap errors fmt.Errorf("...: %w", err)

Runnable example

Save as main.go:

go mod init example
go run .
package main

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

func main() {
    // fmt: formatting
    name := "gopher"
    fmt.Printf("hello %s\n", name)
    fmt.Printf("type=%T value=%#v\n", name, struct{ N int }{N: 7})

    err := fmt.Errorf("open config: %w", os.ErrNotExist)
    fmt.Printf("wrapped: %v\n", err)

    // slog text
    text := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
    text.Debug("boot", "pid", os.Getpid())
    text.Info("ready", "port", 8080)

    // slog JSON
    jsonLog := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    jsonLog.Info("request",
        "method", "GET",
        "path", "/health",
        "status", 200,
        "ms", 3,
    )

    // grouped attrs
    svc := jsonLog.With("service", "demo")
    svc.Warn("slow dependency", "dep", "db", "ms", 120)
}

Expected output (timestamps/pid vary):

hello gopher
type=string value=struct { N int }{N:7}
wrapped: open config: file does not exist
level=DEBUG msg=boot pid=...
level=INFO msg=ready port=8080
{"time":"...","level":"INFO","msg":"request","method":"GET","path":"/health","status":200,"ms":3}
{"time":"...","level":"WARN","msg":"slow dependency","service":"demo","dep":"db","ms":120}

What to notice: - %#v is invaluable when debugging unexpected types. - slog fields are queryable; free-form strings are not. - Same event shape works for text (local) and JSON (shipped).

Try next: Add a custom slog.Handler wrapper that redacts any attribute key named password or token.