Why does a slog.Level decoded from the config string "warning" leave a service logging at Info?
answer
- four names, one shorter than you typed
- a failed parse writes nothing
- what does an untouched Level equal?
- the zero value is neither extreme
- the error was returned; was it read?
basics
~20 sslog.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.
solid answer
~40 s`slog.Level` implements `encoding.TextUnmarshaler`, and its parser recognises exactly four names — `DEBUG`, `INFO`, `WARN`, `ERROR` — matched case-insensitively, optionally followed by a signed offset like `WARN+1` or `INFO-2`. `"warning"` matches none of them, so `UnmarshalText` returns an error and does not touch the target. If the caller ignored that error — or if the config field was absent entirely — the variable is still its zero value, and the zero `slog.Level` is `LevelInfo`. The service is not misconfigured in any way it can detect; it is running the default that nobody chose. The fix is to treat a bad level as a startup failure rather than a warning, and to keep a table test over the exact strings your config documents, asserting each one parses to the level you intended.
code
go · 4 linesvar lvl slog.Level
if err := lvl.UnmarshalText([]byte(cfg.Level)); err != nil {
return fmt.Errorf("log level %q: %w", cfg.Level, err)
}go deeper
Remember that a slog.Level can be read from text at all, that the accepted names are DEBUG, INFO, WARN and ERROR, and that the zero value of slog.Level is Info.
Explain why the failure is silent: UnmarshalText leaves the target untouched on error, and an untouched slog.Level is zero, which is Info — so a dropped error looks exactly like a chosen default.
Show the handling: fail fast at startup, keep the level in force on a bad hot reload and say so loudly, and keep a table test over every level string your config documents.
Own the configuration contract — which level names the platform accepts across services, whether a malformed value halts a rollout or is tolerated, and who is accountable when a fleet quietly runs at a verbosity nobody selected.
## The symptom A controller re-reads its own configuration while running. Someone sets `level: "warning"` intending to quiet it down, or `level: "trace"` intending to open it up, and nothing changes: the service keeps emitting Info and above. No error appears in the logs, because the thing that failed is the code that decides what gets logged. ## What the parser actually accepts `slog.Level` implements both `encoding.TextMarshaler` and `encoding.TextUnmarshaler`: ```go func (l Level) MarshalText() ([]byte, error) func (l *Level) UnmarshalText(data []byte) error ``` The grammar it accepts is narrow and worth memorising: - one of the four names `DEBUG`, `INFO`, `WARN`, `ERROR`, compared **case-insensitively**, so `warn`, `Warn` and `WARN` are all fine; - optionally followed by a signed integer offset, `+N` or `-N`, giving `INFO+2`, `WARN-1`, `ERROR+4`. Everything else fails. `"warning"` fails — the name must be `warn`. `"trace"` and `"notice"` fail, because the package has no such names even if your application declared constants with those meanings. A bare number like `"4"` fails: the parser wants a name, not the underlying integer. The offset form is the escape hatch for custom levels — a level you declared as `slog.LevelInfo + 2` is spelled `INFO+2` in config, which is also exactly what `MarshalText` writes for it, so the representation round-trips. ## Why the failure is silent Two defaults line up badly: 1. **`UnmarshalText` leaves the target untouched when it fails.** It sets `*l` only after it has recognised a name, so a failed parse means the variable still holds whatever it held before — for a fresh `var lvl slog.Level`, that is 0. 2. **The zero `slog.Level` is `LevelInfo`.** That is a deliberate and generally good design choice: an unset level is a sane default rather than the noisiest possible setting. It also means "I failed to parse your config" and "you did not configure anything" and "you asked for Info" are indistinguishable in the resulting value. Add a caller that discards the error — or a decoder configured to ignore unknown or malformed fields — and the misconfiguration is completely invisible. There is a third variant of the same trap: leave `HandlerOptions.Level` nil altogether and the handler also defaults to Info. Whichever way you arrive there, the service runs at a verbosity nobody chose. ## Handling it properly **Fail fast at startup.** A log level that cannot be parsed is a configuration error, and configuration errors should stop the process before it starts serving. Wrap the error with the offending text so the operator sees which value was wrong: ```go var lvl slog.Level if err := lvl.UnmarshalText([]byte(cfg.Level)); err != nil { return fmt.Errorf("log level %q: %w", cfg.Level, err) } ``` **On a live reload, keep the old level and shout.** A controller that re-reads config while running cannot exit on a bad value without taking an outage for a typo. The right behaviour is to reject the new value, keep the level that is currently in force, and emit a record at a level that is guaranteed to be visible saying what was rejected and why. **Decode straight into the thing that is in force.** `*slog.LevelVar` also implements `UnmarshalText`, so a config struct can hold a `*slog.LevelVar` field — or you can call `UnmarshalText` on the very `LevelVar` your handler holds — and a successful reload moves the live floor with no extra plumbing. **Test the exact strings you document.** The cheap, high-value test is a table over every value your config file's documentation permits, asserting each one both parses without error and yields the level you meant. It catches `"warning"` in your own docs, catches a custom level whose offset spelling drifted, and catches the case where someone "tidied" the config schema. ## The rendering asymmetry worth knowing The text form round-trips by value, not by spelling. `WARN-1` parses to the level 3, and `Level(3).String()` returns `INFO+3`, because `String` names a value relative to the nearest named level at or below it. Both texts denote the same level, so a config value and a log line can disagree in spelling while agreeing perfectly on meaning. A test that asserts on the string rather than the numeric level will trip over this. ## Common mistakes - Discarding the error from `UnmarshalText`. - Assuming a failed parse yields Debug, or an invalid level that drops everything. It yields Info. - Hand-rolling a `switch` over level names, which silently diverges from what `MarshalText` writes. - Documenting `warning`, `trace` or `fatal` in a config schema that the standard parser will reject.
- Which strings does slog.Level.UnmarshalText accept?The four names `DEBUG`, `INFO`, `WARN` and `ERROR`, matched case-insensitively, each optionally followed by a signed offset — `WARN+1`, `INFO-2`, `ERROR+4`. That offset form is how a custom level such as `slog.LevelInfo + 2` is spelled in config. Anything else, including `warning`, `trace`, `fatal` and a bare `4`, is rejected.
- A controller re-reads its config while running and the new level string is invalid. Should it exit?No — taking an outage for a typo in a hot-reload path is worse than the typo. Reject the new value, keep the level currently in force, and emit a record at a level guaranteed to be visible naming the rejected string. At process startup the opposite applies: fail before serving, so a bad value never reaches production quietly.
- How do you get a reloaded config value into the level a running handler is using?Have the config decode into, or be applied to, the `*slog.LevelVar` the handler was given — `LevelVar` implements `UnmarshalText` as well, so a successful parse can move the live floor directly. Parse into a temporary first if you want to validate before committing, then `Set` only when the parse succeeded.
saying these in an interview costs you the question
- Ignores the error returned by UnmarshalText
- Assumes a failed parse leaves the level at Debug
- Expects "warning", "trace" or a bare "4" to parse
- Hand-rolls a switch that diverges from MarshalText
- Treats an unparseable startup level as a warning, not a failure