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:
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) }
[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:
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):
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:
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") }
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:
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") }
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:
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") }
{"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:
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")), ) }
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", idrather thanfmt.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
LogValueto 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.
{"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}