Menu

Golang slog and log: Structured Logging in Go

How to log in Go: the classic log package with its flags and log.Fatal, and log/slog (Go 1.21) for structured logs with levels, key-value attributes, text and JSON handlers, and loggers that carry context with With.

This page includes runnable editors - edit, run, and see output instantly.

slog in one example

log/slog (Go 1.21) writes structured records: a message, a level, and key-value attributes.

Each line comes out as time=... level=INFO msg="user logged in" user=ada attempts=1. Because every value is a separate field, a log collector (Loki, Elasticsearch, CloudWatch, Datadog) can filter on user=ada or level=ERROR without regular expressions over free text.

The examples on this page write to os.Stdout so the output shows up in order. In a real service, logs usually go to os.Stderr, which is also where the default logger writes.

Levels

LevelValueUse for
slog.LevelDebug-4details for developers, off in production
slog.LevelInfo0normal events: started, request served, job finished
slog.LevelWarn4something unexpected that the program handled
slog.LevelError8an operation failed

The handler drops records below its minimum level, and the default minimum is Info. That is why slog.Debug(...) prints nothing until you configure a handler with Level: slog.LevelDebug. The gaps between values leave room for custom levels such as slog.Level(2).

To change the level at runtime (from a flag, an admin endpoint, or a signal), put a slog.LevelVar in the options and call Set on it later:

var level slog.LevelVar // zero value: Info
logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: &level}))
level.Set(slog.LevelDebug) // from now on, debug records are written

Text or JSON

slog.NewTextHandler writes key=value pairs, easy to read in a terminal. slog.NewJSONHandler writes one JSON object per line, the format most log pipelines expect. The logging calls stay the same; only the handler changes.

Values keep their types: status is a number in the JSON output and retry a boolean, a time.Duration prints as 42ms in text and as nanoseconds in JSON, and an error prints its message. ReplaceAttr is the hook for rewriting or removing attributes, used here to drop the timestamp, and in practice to rename keys (msg to message) or redact values.

Attributes

The loosely typed form alternates keys and values: "user", "ada", "attempts", 3. It is short, and it has one failure mode: an odd number of arguments. The leftover value is logged under the key !BADKEY. go vet catches it:

./main.go:14:2: call to slog.Info missing a final value

For type safety and a little less allocation, use the attribute constructors, and LogAttrs when logging in a hot path:

logger.Info("order placed",
	slog.Int("order_id", 1017),
	slog.String("currency", "EUR"),
	slog.Float64("total", 59.90),
	slog.Duration("took", elapsed),
)

logger.LogAttrs(ctx, slog.LevelInfo, "order placed", slog.Int("order_id", 1017))

Use one naming convention for keys across the codebase (user_id everywhere, not userID in one package and uid in another). Queries in your log system depend on it.

With: loggers that carry context

logger.With(attrs...) returns a new logger that adds those attributes to every record. Create one per request or per job, and every line it writes can be tied together:

Every line carries service, version, request_id and user without repeating them at each call. slog.Group nests attributes, which the JSON handler writes as a nested object ("payment":{"amount":25,"currency":"USD"}) and the text handler as dotted keys (payment.amount=25). logger.WithGroup("db") puts every later attribute of that logger under a group.

Pass the request-scoped logger down as a parameter or a struct field. Storing it in a context.Context is possible but hides the dependency; slog's InfoContext(ctx, ...) methods pass the context to the handler, which a custom handler can use to pull out trace IDs.

Hiding secrets with LogValuer

A type can control how it is logged by implementing slog.LogValuer. This keeps passwords and tokens out of logs no matter who logs the value:

User logs only its ID and email, and a Token logged on its own prints REDACTED. The handler calls LogValue only when the record is actually written, so it also works for values that are expensive to compute.

The classic log package

log predates slog and is still fine for small programs and scripts. It writes lines to standard error with a date and time prefix:

FlagAdds
log.LstdFlags (the default)2009/11/10 23:00:00 date and time
log.Lmicrosecondsmicroseconds on the time
log.LUTCtime in UTC
log.Lshortfile / log.Llongfilemain.go:14 / the full path
log.Lmsgprefixputs the prefix before the message instead of at the start of the line

Three functions exit or panic, and the difference matters:

  • log.Fatal, log.Fatalf, log.Fatalln print and then call os.Exit(1). Deferred calls do not run. Use them in main for startup failures, never in library code or request handlers.
  • log.Panic and friends print and then panic, so deferred calls run and the panic can be recovered.
  • Every other function just writes a line.

To log to a file, open it and pass it to log.New or log.SetOutput; io.MultiWriter(os.Stderr, f) writes to both.

log and slog together

slog.SetDefault(logger) makes logger the default for the top-level slog.Info functions, and also routes the log package's output through it. Existing log.Printf calls in your code or in dependencies then come out as structured records at Info level:

slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stderr, nil)))
log.Printf("legacy message") // {"time":"...","level":"INFO","msg":"legacy message"}

Before SetDefault, the default slog logger writes through the log package, which is why a bare slog.Info("hi") prints 2026/09/23 14:30:00 INFO hi.

Practical rules

  • Log or return an error, not both. A function that logs an error and returns it gets the same failure logged at every level of the call stack. Return errors upward with context, and log once where they are handled.
  • Put variable data in attributes, not in the message. logger.Info("user created", "user_id", id) groups well in a log system; logger.Info(fmt.Sprintf("user %d created", id)) creates a different message for every user.
  • Never log secrets or full request bodies. Use LogValuer or ReplaceAttr to redact.
  • Use JSON in production, text in development. Pick the handler at startup from a flag or an environment variable.
  • Choose levels deliberately. If everything is logged at Error, alerts on errors become noise.

Frequently Asked Questions

What is slog in Go?

log/slog is the structured logging package added to the standard library in Go 1.21. Instead of formatted strings, each record has a message, a level (Debug, Info, Warn, Error) and key-value attributes, and a handler writes it as key=value text or as JSON: slog.Info("login", "user", "ada", "attempts", 3).

How do I enable debug logs in slog?

The default minimum level is Info, so slog.Debug prints nothing. Create a handler with a lower level and make it the default: slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))). Use a slog.LevelVar instead of a constant if you want to change the level while the program runs.

What is the difference between log and slog in Go?

log writes free-form lines with an optional timestamp prefix and has no levels. slog writes records with levels and typed key-value attributes that log collectors can parse and filter. Both are in the standard library; slog.SetDefault also redirects the output of the log package through the slog handler.

Does log.Fatal run deferred functions?

No. log.Fatal and log.Fatalf print the message and call os.Exit(1), which skips all deferred calls. Use them only in main or setup code where there is nothing to clean up. log.Panic panics instead, so deferred calls do run.

Coddy programming languages illustration

Learn to code with Coddy

GET STARTED