Why does slog.Level space its constants four apart, and how do you add a custom level between them?
answer
- the constants are not 0,1,2,3
- count the free integers between them
- a plain constant is enough
- no Notice helper on the logger
- printed name is relative to below
basics
~20 sslog.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.
solid answer
~40 s`slog.Level` is `type Level int`, and the named constants are deliberately spaced four apart so integrations and applications can slot their own levels in without redefining the scale — a Notice between Info and Warn, or a Trace below Debug. You declare one as an ordinary constant, `const LevelNotice = slog.LevelInfo + 2`, and log with `logger.Log(ctx, LevelNotice, "msg", args...)`, since there is no generated `logger.Notice` helper. Filtering needs no extra work: the handler's comparison is numeric, so a Notice record passes any floor at Info or lower. The one rough edge is rendering — `Level.String` prints an unnamed value relative to the nearest named one below it, so 2 prints as `INFO+2`. To get `NOTICE` in the output you rewrite the level attribute in `HandlerOptions.ReplaceAttr`.
code
go · 4 linesconst LevelNotice = slog.LevelInfo + 2 // between INFO (0) and WARN (4)
logger.Log(ctx, LevelNotice, "config reloaded", "generation", 7)
// the default rendering of that level is INFO+2go deeper
Know that the four level constants are ints, not strings, and that Debug is -4 while Error is 8. That alone explains why arithmetic on them is legal.
Be ready to declare a constant between two named levels, name the method that takes a level argument, and explain why the handler needs no changes to filter it.
Show the rendering trap: an unnamed level prints as INFO+2 unless you rewrite the level attribute, and log pipelines that group by severity have to be told about the new value.
Own the call on whether a new severity is worth its coordination cost across dashboards, alerts and downstream consumers, versus expressing the distinction as an attribute instead.
## The scale `log/slog` defines severity as an integer type: ```go type Level int const ( LevelDebug Level = -4 LevelInfo Level = 0 LevelWarn Level = 4 LevelError Level = 8 ) ``` The values are not arbitrary. Info is zero so that the zero value of a `slog.Level` — an unset struct field, a variable that was never assigned — is a sensible default rather than the noisiest possible setting. And the steps of four are there to leave room: three unnamed integers sit between each pair of named levels, and the space above Error and below Debug is unbounded. ## Why the gaps matter Most logging systems people migrate from have more than four severities: syslog has eight, other libraries have Trace, Notice, Fatal, and so on. If `slog`'s constants were 0, 1, 2, 3, there would be nowhere to put those without renumbering. With gaps of four you can map an existing scale onto `slog`'s and keep the ordering intact, which matters when logs from several sources land in one aggregator and must sort consistently. ## Declaring one A custom level is just a constant of the type: ```go const ( LevelTrace = slog.LevelDebug - 4 LevelNotice = slog.LevelInfo + 2 LevelFatal = slog.LevelError + 4 ) ``` Nothing registers them anywhere. There is no table of valid levels, no init hook, and no handler that has to be taught about them. A level is only ever compared as an integer. ## Emitting a record at one `*slog.Logger` has convenience methods for the four named levels — `Debug`, `Info`, `Warn`, `Error` — and one general method underneath them: ```go func (l *Logger) Log(ctx context.Context, level Level, msg string, args ...any) func (l *Logger) LogAttrs(ctx context.Context, level Level, msg string, attrs ...Attr) ``` So a custom level is logged with `logger.Log(ctx, LevelNotice, "config reloaded", "generation", 7)`. Teams that use a custom level a lot usually wrap it in their own helper method on a small logger type, but the wrapper is theirs to write; the package does not generate one. ## Filtering is free Because the handler's decision is `record.Level >= floor`, custom levels need no special support. A handler whose floor is `slog.LevelInfo` emits `LevelNotice` (2) and drops `LevelTrace` (-8) without knowing either name exists. This is the payoff of making levels integers rather than an enumerated set: third-party handlers written before your level existed still route it correctly. ## Rendering: the rough edge `Level.String` has to name a value it has no name for, and it does so relative to the nearest named level at or below it: - `Level(2)` prints `INFO+2` - `Level(-6)` prints `DEBUG-2` - `Level(3)` prints `INFO+3` — note that a value parsed from the text `WARN-1` renders as `INFO+3`, because 3 is closer to Info from above than to Warn from below. - `Level(12)` prints `ERROR+4` That round-trips (the same text parses back to the same value), but `INFO+2` is not what an operator searching for `NOTICE` wants to see. The fix is `HandlerOptions.ReplaceAttr`, a callback the built-in handlers run over each attribute before writing it. Match on the key `slog.LevelKey`, recover the `slog.Level` from the attribute's value, and substitute a string: ```go ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { if a.Key == slog.LevelKey && a.Value.Any().(slog.Level) == LevelNotice { a.Value = slog.StringValue("NOTICE") } return a } ``` Doing this changes only the rendered text; the filtering still uses the integer. ## When not to add one A custom level is cheap to declare and expensive to socialise: every dashboard, alert rule and log query that groups by severity has to learn it, and any consumer that maps `slog` levels onto a fixed set has to decide what an unknown value means. Adding Trace below Debug is usually uncontroversial; adding four levels between Info and Warn generally means the team is using severity to express a category, which an attribute expresses better. ## Common mistakes - Reaching for `slog.LevelNotice`. It does not exist; you declare your own. - Calling `logger.Info` and hoping the level argument overrides it. `Log` is the method that takes a level. - Expecting the output to say `NOTICE` without a `ReplaceAttr`. - Assuming a custom level needs a custom handler. It does not — the comparison is numeric.
- What does Level.String print for a custom level of slog.LevelInfo+2, and can it be parsed back?It prints `INFO+2`: the method names the value relative to the nearest named level at or below it and appends a signed offset. That text round-trips — `UnmarshalText` accepts a name plus an offset — so a level written to config or to a log line can be read back exactly. It just is not the label an operator expects to search for.
- Does a third-party handler written before your custom level existed handle it correctly?For filtering, yes: the decision is the integer comparison `record.Level >= floor`, which needs no knowledge of names. Rendering is the part that can look wrong — a handler that maps levels onto a fixed vocabulary has to decide what to do with an unrecognised value, and most fall back to the `INFO+2` style string or to the nearest named level.
- When is a custom level the wrong tool?When you are really expressing a category rather than a severity. Severity is what dashboards group by and alerts trigger on, and every new value has to be taught to all of them. If the distinction is "this is an audit event" or "this is a slow query", that belongs in an attribute you can filter on, with the severity left at Info or Warn.
saying these in an interview costs you the question
- Refers to a slog.LevelNotice constant that does not exist
- Thinks a custom level requires writing a custom handler
- Expects the output to print NOTICE with no ReplaceAttr
- Says levels are strings so any name works
- Uses Logger.Info to emit a record above Info