Logging with slog

Logs are how a running program tells you what it's doing. Plain text lines are fine on your laptop, but in production you want structured logs: each entry a message plus key–value fields that tools can search and filter. Go's log/slog package provides exactly that, with levels, text or JSON output, and loggers that carry context. This lesson also shows the classic log package you'll see in small programs.

The classic log package

log writes a timestamped line to standard error. log.Fatal logs and then exits with status 1, which is handy in main for unrecoverable setup errors:

GO
package main

import (
	"log"
	"os"
)

func main() {
	log.SetOutput(os.Stdout) // standard error is the default
	log.SetFlags(0)          // drop the timestamp so this example's output is stable
	log.SetPrefix("[app] ")

	log.Println("starting")
	log.Printf("loaded %d routes", 12)
}
Output
[app] starting
[app] loaded 12 routes

By default each line starts with the date and time, in the form 2026/10/08 17:54:42 starting.

Structured logging with slog

slog's functions take a message followed by alternating keys and values. The default output looks like the classic logger's, with the fields appended:

GO
package main

import "log/slog"

func main() {
	slog.Info("order placed", "order_id", 1042, "total", 99.95, "customer", "ana")
	slog.Warn("stock low", "sku", "LMP-01", "remaining", 2)
}

prints lines like these (from a real run; the timestamps will differ for you):

Output (timestamps vary)
2026/10/08 17:54:42 INFO order placed order_id=1042 total=99.95 customer=ana
2026/10/08 17:54:42 WARN stock low sku=LMP-01 remaining=2

Handlers: text and JSON

A handler decides how entries are written. slog.NewTextHandler writes key=value pairs and slog.NewJSONHandler writes one JSON object per line — the format most log collectors prefer. The examples below remove the time field with ReplaceAttr, only so that their output is the same on every run:

GO
package main

import (
	"log/slog"
	"os"
)

func noTime(groups []string, a slog.Attr) slog.Attr {
	if a.Key == slog.TimeKey && len(groups) == 0 {
		return slog.Attr{} // drop it
	}
	return a
}

func main() {
	opts := &slog.HandlerOptions{ReplaceAttr: noTime}

	text := slog.New(slog.NewTextHandler(os.Stdout, opts))
	text.Info("user signed in", "user", "ana", "method", "passkey")

	jsonLog := slog.New(slog.NewJSONHandler(os.Stdout, opts))
	jsonLog.Info("user signed in", "user", "ana", "method", "passkey")
	jsonLog.Error("payment failed", "order_id", 1042, "err", "card declined")
}
Output
level=INFO msg="user signed in" user=ana method=passkey
{"level":"INFO","msg":"user signed in","user":"ana","method":"passkey"}
{"level":"ERROR","msg":"payment failed","order_id":1042,"err":"card declined"}

Make a logger the program-wide default with slog.SetDefault(logger); then the top-level slog.Info and friends — and the classic log package — go through it.

Levels

slog has four levels: Debug, Info, Warn and Error. A handler's minimum level filters out anything less important — typically Info in production and Debug while investigating a problem. A slog.LevelVar can be changed while the program runs:

GO
package main

import (
	"log/slog"
	"os"
)

func main() {
	var level slog.LevelVar // starts at Info
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
		Level: &level,
		ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
			if a.Key == slog.TimeKey {
				return slog.Attr{}
			}
			return a
		},
	}))

	logger.Debug("cache lookup", "key", "user:7") // filtered out
	logger.Info("request handled", "status", 200)

	level.Set(slog.LevelDebug) // e.g. after receiving a signal
	logger.Debug("cache lookup", "key", "user:7")
}
Output
level=INFO msg="request handled" status=200
level=DEBUG msg="cache lookup" key=user:7

Loggers with context

With returns a logger that adds the same fields to every entry — a request ID, a user, a component name — so you don't repeat them. WithGroup and slog.Group nest fields under a name:

GO
package main

import (
	"log/slog"
	"os"
)

func handleRequest(base *slog.Logger, reqID, path string) {
	log := base.With("request_id", reqID, "path", path)
	log.Info("started")
	log.Info("finished", slog.Group("response", "status", 200, "bytes", 5120))
}

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
		ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
			if a.Key == slog.TimeKey && len(groups) == 0 {
				return slog.Attr{}
			}
			return a
		},
	}))
	handleRequest(logger.With("service", "api"), "a1b2", "/orders")
}
Output
{"level":"INFO","msg":"started","service":"api","request_id":"a1b2","path":"/orders"}
{"level":"INFO","msg":"finished","service":"api","request_id":"a1b2","path":"/orders","response":{"status":200,"bytes":5120}}

Typed attributes and sensitive data

Functions such as slog.Int, slog.String and slog.Duration build typed fields; they're slightly faster and catch mistakes like an odd number of key–value arguments (which go vet also reports). A type can control how it's logged with a LogValue method — useful to keep secrets out of logs:

GO
package main

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

type Token string

func (Token) LogValue() slog.Value { return slog.StringValue("[REDACTED]") }

func main() {
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
		ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
			if a.Key == slog.TimeKey {
				return slog.Attr{}
			}
			return a
		},
	}))
	logger.Info("api call",
		slog.String("endpoint", "/charge"),
		slog.Int("attempt", 2),
		slog.Duration("took", 340*time.Millisecond),
		slog.Any("token", Token("sk_live_123")),
	)
}
Output
level=INFO msg="api call" endpoint=/charge attempt=2 took=340ms token=[REDACTED]

Good logging habits

  • Write a short, constant message and put the variable parts in fields: "payment failed", "order_id", id rather than fmt.Sprintf("payment %d failed", id). Constant messages are easy to search for.
  • Log an error once, where it's handled — not at every level it's returned through.
  • Never log passwords, tokens or personal data; use LogValue to redact types that hold them.
  • Use JSON output in production, where logs are collected and searched by machines.

Try it yourself

Create a JSON logger for a "worker" component and log the start and end of three jobs, adding a job field with With.

Try it yourself · main.go
Edit the code, copy it into main.go in a new folder and run it with go run main.go (Go 1.27 or later). How to install Go
Output
{"level":"INFO","msg":"job started","component":"worker","job":1}
{"level":"INFO","msg":"job started","component":"worker","job":2}
{"level":"WARN","msg":"job slow","component":"worker","job":2,"seconds":12}
{"level":"INFO","msg":"job started","component":"worker","job":3}
Next: testing your code.