skip to content

Structured Logging With slog

Emitting machine-readable records with log/slog: a Logger over a JSON or text Handler, typed attributes and groups, and a level a running process can raise.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

explore

questions

13

In Go's log/slog, how do the variadic key/value arguments work, and what is !BADKEY?

level: juniorimportance: must knowfreq 76%

answer

  1. arguments arrive two at a time
  2. a string key, then its value
  3. or a ready-made slog.Attr instead
  4. one leftover argument still needs a key
  5. vet notices the odd count

basics

~20 s

log/slog reads its variadic arguments as alternating pairs: a string key followed by its value, or a ready-made slog.Attr such as slog.String("pkg", p). A key left with no value is logged under the placeholder key !BADKEY.

solid answer

~50 s

Every log/slog logging call takes a message plus a variadic list of `any`, and slog turns that list into attributes. It reads a string as a key and takes the next argument as that key's value; if it instead finds a `slog.Attr` built by a constructor like `slog.String`, `slog.Int` or `slog.Any`, it uses that attribute directly, so both styles can be mixed in one call. The typed constructors make the value's type visible at the call site and can be stored and passed around, for example into `slog.Group`. When the argument list ends with a dangling key, slog neither panics nor drops it: it records an attribute whose key is the literal string `!BADKEY` and whose value is the orphan argument, so the mistake shows up in the log line instead of breaking the program. `go vet` has a slog analyzer that reports such mismatched calls statically.

code

go · 8 lines
go
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))

// a key/value pair and a typed Attr in the same call
logger.Info("generated", "pkg", "store", slog.Int("decls", 12))

// the trailing key has no value to pair with
logger.Info("generated", "pkg", "store", "count")
// the record carries pkg=store and !BADKEY=count

go deeper

for a junior

Be ready to write a slog call from memory: a constant message, then key/value pairs, and know that slog.String and slog.Int build the same attribute in typed form.

for a middle

Explain how slog walks the argument list, when it consumes one argument versus two, and exactly what a reader should conclude when !BADKEY shows up in a line.

for a senior

Show how you keep malformed calls out of production: go vet's slog analyzer wired into CI, and a review habit of preferring typed attribute constructors where the value's type is not obvious.

for a principal

Own the convention across teams: whether services standardise on typed attribute constructors or loose pairs, and how that is enforced by tooling rather than relitigated in every pull request.

## The two argument forms The logging methods in `log/slog` — `Logger.Info`, `Logger.Warn`, `Logger.Error`, `Logger.Debug` and `Logger.Log` — take a message string followed by `args ...any`. That loose signature is deliberate: it lets one call carry either cheap key/value pairs or fully typed attributes, and lets a codebase migrate from one to the other without changing the call shape. slog walks the `args` slice from the front and, at each step, looks at the next value: - If it is a **string**, slog treats it as a key and consumes the *following* argument as that key's value. Two arguments are used. - If it is a **`slog.Attr`**, slog uses it as-is. One argument is used. - Anything else in a key position cannot be a key, so slog records it under `!BADKEY` and consumes only that one argument. A `slog.Attr` is a tiny struct: a `Key string` and a `Value slog.Value`. The constructors `slog.String`, `slog.Int`, `slog.Int64`, `slog.Uint64`, `slog.Float64`, `slog.Bool`, `slog.Time`, `slog.Duration` and the catch-all `slog.Any` each return one. Because an `Attr` is an ordinary value, you can build it once, keep it in a variable, pass it to another function, or hand it to `slog.Group` as part of a nested set. ## What !BADKEY actually is `!BADKEY` is a literal string constant inside `log/slog`, used as the key for an argument slog could not interpret. Two situations produce it. The first is the dangling key. `logger.Info("generated", "pkg", "store", "count")` has an odd number of arguments after the message. The pair `"pkg", "store"` is consumed normally; then `"count"` is a string with nothing after it, so slog emits an attribute keyed `!BADKEY` whose value is the string `count`. Text output shows `!BADKEY=count`; JSON output shows `"!BADKEY":"count"`. The second is a non-string in a key position. `logger.Info("generated", 12, "pkg", "store")` starts with `12`, which cannot be a key, so slog records `!BADKEY=12` and consumes only that one argument. Everything after it has now shifted by one, so `"pkg"` becomes a *value* and `"store"` becomes a *key* — the whole tail of the record comes out mis-keyed. That cascade is why the mistake is worth catching statically rather than eyeballing the output. The design choice behind `!BADKEY` is that logging must not be able to take down the program it is observing. A malformed call produces a malformed line, not a panic and not a compile error, because the signature is `...any` and the compiler has nothing to check. ## Catching it before it ships `go vet` includes a slog analyzer that understands these call shapes. It reports calls where the key/value arguments do not pair up and calls where a key is not a string constant it can prove is a string, at build time and with no runtime cost. Running `go vet ./...` in CI turns a class of silently broken log lines into a failed check, which is the whole point: the code that emits a log line is usually the code nobody exercises in a test. For the engineer arriving from a language where logging means formatting, the mental shift is that there is no format string here. `logger.Info("generated %s", pkg)` compiles and runs, and produces the message `generated %s` with no attributes at all — a perfectly valid record that says nothing useful. The message is a constant human-readable string; everything variable belongs in attributes, where a downstream consumer can index and query it. ## Practical guidance Use plain key/value pairs where the call is short and the values are obviously typed. Reach for the typed constructors when the value's type is not obvious from the expression, when you want the compiler to check it, or when you need the attribute as a value — building a group, or collecting attributes in a slice before logging. Keep key names consistent across a service, because the consumer queries on those strings, and remember that keys are just strings: nothing stops two layers from choosing the same one.

  • What happens if a non-string value turns up where slog expects a key?
    slog cannot use it as a key, so it records that single argument under !BADKEY and moves on. Because one argument was consumed instead of two, every later pair shifts by one and the rest of the record comes out mis-keyed. That cascade is why go vet's slog analyzer treats a non-string key as an error rather than a style nit.
  • Why offer slog.String and slog.Int when a bare key and value already work?
    They build a slog.Attr, which is an ordinary value you can name, pass to another function, or hand to slog.Group when nesting. They also put the value's type in the source, so a reviewer sees what is being logged. A loose pair is interpreted only at run time, and a mistake in its shape surfaces as !BADKEY in a log line rather than as a compile error.
  • Can a badly formed slog call ever stop the program?
    No. slog is deliberately forgiving at run time: a dangling or non-string key becomes an !BADKEY attribute and the record is still written, so the damage is a malformed log line rather than an outage. Nothing panics and nothing is silently discarded. The check you want is static, and that is go vet reporting the mismatched call.

The argument list is read like a shopping list of label-then-item. Hand over a label with no item behind it and slog does not throw the list away — it files the stray label in a drawer marked !BADKEY so you can see what went wrong.

saying these in an interview costs you the question

  • Thinks log/slog takes a printf-style format string
  • Says a dangling key is silently dropped
  • Claims an odd argument count fails to compile
  • Believes a mismatched slog call panics at run time
  • Assumes only slog.Any can carry a value
open as a page

In Go's log/slog, what does the Level field of slog.HandlerOptions control, and what happens if you leave it nil?

level: juniorimportance: must knowfreq 60%

basics

~10 s

HandlerOptions.Level sets the minimum severity a slog handler emits: records at that level or higher are written, lower ones are dropped. Leave it nil and the handler defaults to slog.LevelInfo, so Debug records disappear.

open as a page

What does slog.SetDefault change, and where do slog.Info records go before it is called?

level: juniorimportance: must knowfreq 72%

basics

~20 s

slog.SetDefault installs a *slog.Logger as the process-wide default, so the package-level slog.Info, slog.Warn and slog.Error use its handler. Before that call, those functions use a built-in handler that prints plain, unstructured lines to standard error.

open as a page

In Go's log/slog, how do slog.Group and Logger.WithGroup differ in what they nest?

level: middleimportance: should knowfreq 56%

basics

~10 s

slog.Group builds one attribute whose value is a nested set of attributes, for a single record. Logger.WithGroup returns a derived logger that qualifies everything added afterwards under that group name, on every record.

open as a page

Why does slog.Level space its constants four apart, and how do you add a custom level between them?

level: middleimportance: should knowfreq 40%

basics

~20 s

slog.Level is an int — Debug -4, Info 0, Warn 4, Error 8 — and the gaps exist so you can define levels in between. Declare a constant such as slog.LevelInfo+2 and emit records with Logger.Log.

open as a page

How do slog.NewTextHandler and slog.NewJSONHandler differ in what they emit for the same record?

level: middleimportance: should knowfreq 62%

basics

~10 s

Both write one line per record with the same built-in keys, but TextHandler emits key=value pairs for a human and JSONHandler emits an object for a machine. They disagree on nested values.

open as a page

Why do log/slog JSON records sometimes contain the same key twice, and how do you prevent it?

level: seniorimportance: should knowfreq 45%

basics

~20 s

log/slog does not deduplicate attributes. If a derived logger already carries file and a call site passes file again, the JSON handler writes both keys. Give each layer its own group so its names cannot collide.

open as a page

How do you flip a running Go service's slog verbosity to debug without restarting it or rebuilding the logger?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Give the handler a *slog.LevelVar as its HandlerOptions.Level instead of a plain constant. The handler reads it for every record, so calling Set on that LevelVar from a signal handler, an admin endpoint or a config reload changes verbosity immediately.

open as a page

With slog's HandlerOptions.AddSource enabled, why does every record's source point at one helper file?

level: seniorimportance: should knowfreq 34%

basics

~20 s

AddSource resolves the program counter stored on the record, and slog's logging methods capture that counter at a fixed call depth. If every call goes through your own helper, the helper is what every record reports.

open as a page

As the owner of a Go fleet's slog defaults, how do you choose between NewJSONHandler and NewTextHandler, and whether AddSource is on?

level: principalimportance: should knowfreq 28%

basics

~20 s

Decide by consumer: JSON wherever a collector parses the stream, text only where a human reads it. Put the choice in one shared setup function called from each main, and forbid libraries from calling slog.SetDefault.

open as a page

In Go's log/slog, why does the order of Logger.With and Logger.WithGroup change the output?

level: middleimportance: nice to knowfreq 34%

basics

~10 s

Logger.With and Logger.WithGroup each return a new logger and are applied in call order: attributes added before a WithGroup stay at the top level, while those added after it are nested inside that group.

open as a page

When would you use slog.NewMultiHandler, and what decides whether a record reaches every handler you gave it?

level: middleimportance: nice to knowfreq 26%

basics

~10 s

It fans one record out to several handlers, so one slog call can produce readable text on a terminal and JSON for a collector. Each wrapped handler still decides for itself whether it writes.

open as a page

Why does a slog.Level decoded from the config string "warning" leave a service logging at Info?

level: seniorimportance: nice to knowfreq 28%

basics

~20 s

slog.Level.UnmarshalText accepts only DEBUG, INFO, WARN or ERROR, case-insensitively, plus an offset such as WARN+1. "warning" is not a name it knows, so it returns an error and leaves the Level at its zero value, which is Info.

open as a page