skip to content

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%

answer

  1. a floor, not a guest list
  2. the field's type is an interface
  3. the constants are ints, one negative
  4. omitting it still picks a level
  5. the unset default sits above Debug

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.

solid answer

~40 s

`slog.HandlerOptions.Level` is the floor a handler applies to every record. Its type is `slog.Leveler`, an interface with a single `Level() slog.Level` method, so you can assign a plain constant such as `slog.LevelDebug` (a `slog.Level` returns itself) or a `*slog.LevelVar` you can change later. Levels are plain ints — `LevelDebug` is -4, `LevelInfo` 0, `LevelWarn` 4, `LevelError` 8 — and the built-in text and JSON handlers keep a record when its level is greater than or equal to the floor, so *more verbose means a lower number*. If you pass nil options, or leave the field nil, the floor is `slog.LevelInfo`; it does not mean "no filter". That is the usual reason a team's `logger.Debug` calls produce nothing at all.

code

go · 7 lines
go
opts := &slog.HandlerOptions{Level: slog.LevelDebug}
verbose := slog.NewJSONHandler(os.Stdout, opts)
quiet := slog.NewJSONHandler(os.Stdout, nil) // no options: the floor is LevelInfo

ctx := context.Background()
fmt.Println(verbose.Enabled(ctx, slog.LevelDebug)) // true
fmt.Println(quiet.Enabled(ctx, slog.LevelDebug))   // false

go deeper

for a junior

Be ready to say, without hesitating, that the field is a minimum severity and that leaving it nil gives you Info rather than everything. Know the four constants and that Debug is the lowest.

for a middle

Explain that the field's type is slog.Leveler, that the handler asks it for a level on every record, and that the numeric ordering (-4, 0, 4, 8) is what the comparison actually uses.

for a senior

Show that you know the floor lives on the handler, not the logger, and that this decides how you give one process two different verbosities — two handlers, not two loggers.

for a principal

Be able to argue what a service's default floor should be when nobody configures it, and why the standard library chose Info rather than Debug as the zero value everyone inherits.

## The package and the pieces `log/slog` is Go's standard-library structured logger. A `*slog.Logger` is a thin front end; the component that decides where a record goes and how it is rendered is a `slog.Handler`. The two handlers that ship with the package, `slog.NewTextHandler` and `slog.NewJSONHandler`, are both constructed as `New...Handler(w io.Writer, opts *slog.HandlerOptions)`, and `HandlerOptions` is where the level floor lives. ## What the field means `HandlerOptions.Level` is the **minimum severity the handler will emit**. Every record carries a level; the handler compares that level against the floor and drops anything below it. The comparison is `record.Level >= floor`, and the same comparison is exposed as `Handler.Enabled(ctx, level) bool`, which reports whether a record at that level would be handled. ## Levels are integers, and Debug is negative `slog.Level` is declared as `type Level int`. The named constants are: - `slog.LevelDebug` = -4 - `slog.LevelInfo` = 0 - `slog.LevelWarn` = 4 - `slog.LevelError` = 8 Because the ordering is numeric and `Debug` is *below* `Info`, lowering the floor makes the service noisier. People coming from systems where a bigger verbosity number means more output get this backwards: setting the floor to `LevelError` (8) is the quietest configuration, not the loudest. ## The field's type is an interface, not a constant The declared type is `slog.Leveler`: ```go type Leveler interface { Level() Level } ``` Two things in the package satisfy it. `slog.Level` itself has a `Level()` method that returns the receiver, so `Level: slog.LevelDebug` compiles and pins the floor for the life of the handler. `*slog.LevelVar` also satisfies it, and because the handler calls `Level()` on **every record**, a `*LevelVar` gives you a floor you can move while the program runs. Choosing between those two at construction time is the whole difference between a static and a dynamically adjustable service. ## The nil case, which is the one that bites Both `New...Handler(w, nil)` and `&slog.HandlerOptions{}` are legal, and both mean the same thing: **the floor is `slog.LevelInfo`**. It does not mean "unfiltered" and it does not mean "disabled". The observable symptom is that `logger.Debug(...)` calls produce no output whatsoever while `logger.Info(...)` works, and a newcomer concludes that Debug is broken or that the writer is wrong. It is neither; the default floor simply sits above Debug. The same default is why copying a handler construction from one service to another can silently change verbosity: the options struct that was carefully configured in one place is easy to drop when the call is reduced to `slog.NewJSONHandler(os.Stdout, nil)`. ## The floor belongs to the handler There is no level stored on the `*slog.Logger`. A logger is a handler plus (through `With`) some pre-bound attributes, so every logger built on one handler shares that handler's floor, and derived loggers keep sharing it. If you want two different floors — say, quiet on stdout and everything to a debug file — that is two handlers, not two loggers. ## The cost of a dropped record The level check happens before the record is formatted. A record below the floor costs an integer comparison and, at most, the construction of the arguments you already evaluated at the call site; it is never serialized to JSON and never written. That is why leaving `logger.Debug` calls in production code is cheap, provided the arguments themselves are cheap to build. ## Common mistakes - Assuming nil means "log everything". It means Info. - Assuming a bigger number is more verbose. Debug is -4. - Trying to change the floor later on a handler that was given a plain `slog.Level` constant. You cannot; you would need a `*slog.LevelVar` from the start, or a new handler. - Looking for a `SetLevel` method on `*slog.Logger`. There is none — the floor lives in the handler's options.

  • If a logger's floor is slog.LevelWarn, what does a call to logger.Info produce?
    Nothing is written. `LevelInfo` is 0 and `LevelWarn` is 4, so the record's level is below the floor and the handler drops it before formatting. The call still costs whatever it took to evaluate the arguments you passed, but no output is produced and no error is reported — silent dropping is the intended behaviour.
  • Can two loggers built from the same handler have different level floors?
    No. The floor is a property of the handler's options, and `Logger.With` derives a new logger over a derived handler that keeps the same `Leveler`. Two different floors means two handlers — for example a JSON handler at Info on stdout and a second handler at Debug on a file — optionally fanned out from one logger.
  • Why does slog.Level use negative numbers at all?
    So that `slog.LevelInfo` is the zero value. A `var l slog.Level` you never assign, or a `slog.Level` field left out of a decoded config struct, is Info — a sane default rather than the most verbose setting. Putting Debug below zero is what buys that, and it is also why the ordering feels inverted at first.

It is a water line on a dam, not a guest list: everything at or above the line flows through, everything below is held back — and if you draw no line, one is drawn for you at Info.

saying these in an interview costs you the question

  • Thinks nil HandlerOptions.Level means log everything
  • Says Debug is numerically higher than Info
  • Looks for a SetLevel method on slog.Logger
  • Expects to change the floor on a handler built with a constant
  • Believes dropped records are formatted and then discarded