skip to content

In Go's log/slog, why does a `slog.Debug` call still cost work when the logger's level is Info?

level: juniorimportance: must knowfreq 52%

answer

  1. Go has no lazy arguments
  2. the call runs before the check
  3. each value is boxed into any first
  4. guard with Logger.Enabled

basics

~20 s

Go evaluates every argument before the call runs, so work you do to build a log line happens even at a disabled level. slog only checks the level inside the call, after the arguments are boxed into interfaces.

solid answer

~40 s

`logger.Debug("decoded", "summary", summarize(buf))` is an ordinary function call, and Go has no lazy arguments and no macros. `summarize(buf)` runs first, the key and the value are packed into a `[]any`, and each non-pointer value is boxed into an interface value, which usually means a heap allocation. Only then does the logger ask its handler whether Debug is enabled and return. What a disabled level saves is everything downstream of that check: the timestamp, the caller PC, building the `slog.Record`, converting your arguments into attributes, and the handler's formatting and writing. What it does not save is argument evaluation and boxing at the call site. When an argument is genuinely expensive to produce, guard the call with `logger.Enabled(ctx, slog.LevelDebug)`; for cheap fields the guard is noise.

code

go · 7 lines
go
// summarize(buf) runs even when the level is Info.
logger.Debug("decoded record", "summary", summarize(buf))

// Ask the handler first, then decide whether to build the argument.
if logger.Enabled(ctx, slog.LevelDebug) {
	logger.Debug("decoded record", "summary", summarize(buf))
}

go deeper

for a junior

Be ready to say that Go evaluates arguments before the call begins, so a disabled Debug line still runs whatever you passed into it. Naming Logger.Enabled as the way to skip an expensive argument is enough here.

for a middle

Explain precisely what is saved and what is not: the timestamp, the record, the attribute conversion and the handler's work are skipped; argument evaluation and interface boxing are not. Say where the level comparison happens.

for a senior

Show judgment about placement. Guard expensive arguments in hot paths, leave cheap ones alone, and be ready to justify the choice with a benchmark rather than a rule of thumb.

for a principal

Own the convention. Decide whether the codebase guards Debug calls at all, whether expensive dumps are acceptable in library code others put in their hot loops, and how to keep a narrow rule from being applied everywhere by reflex.

## The shape of a slog call `log/slog` gives a `*slog.Logger` one method per level, each with the same signature shape: `Debug(msg string, args ...any)`, and likewise `Info`, `Warn` and `Error`. A typical call looks like this: ```go logger.Debug("decoded record", "summary", summarize(buf)) ``` That is an ordinary Go function call, and Go has exactly one calling discipline: **every argument expression is evaluated, left to right, before the callee's first statement runs.** Go has no macros, no lazily-evaluated parameters, and no compile-time knowledge of your logging level. So `summarize(buf)` executes whether or not a single byte is ever written. ## What is built before slog sees anything Two separate costs land at the call site, before the level is consulted. **Argument evaluation.** Anything you wrote as an argument runs: a `fmt.Sprintf`, a `json.Marshal`, a `string(buf)` conversion that copies a whole buffer, a walk over a struct. This is the cost that actually hurts, because you control how big it is. **Interface boxing.** The parameter is `...any`, so the compiler builds a `[]any` at the call site and converts each argument to an interface value. An interface value is a pair of words: a type descriptor and a pointer to the data. A value that is not already pointer-shaped — an `int`, a `time.Duration`, a struct, a string header — has to live somewhere the pointer can point at, and when the compiler cannot prove the value stays within the frame, that means a heap allocation. In practice a variadic slog call costs the slice plus roughly one allocation per non-pointer value. ## What the disabled level really saves The level comparison happens inside the logging method, and it happens early — before the record exists. Once it fails, slog skips: - reading the clock for the record's timestamp; - capturing the caller's program counter (the source location, if the handler wants it); - constructing the `slog.Record` and converting your alternating key/value arguments into `slog.Attr` values; - the handler entirely: no formatting, no JSON escaping, no lock, no write. That is a large saving, and it is why a disabled level is cheap. It is not free, and "cheap" stops being good enough when the call sits inside a loop running tens of thousands of times a second. ## The guard The escape hatch is `Logger.Enabled`: ```go if logger.Enabled(ctx, slog.LevelDebug) { logger.Debug("decoded record", "summary", summarize(buf)) } ``` `Enabled(ctx context.Context, level Level) bool` forwards to the handler's own `Enabled` method, so it answers the same question the logging method would have asked, just early enough that you can skip building the argument. For the built-in text and JSON handlers that comparison is against `HandlerOptions.Level`, which defaults to Info when you pass `nil` options. A custom handler may answer differently — that is precisely why the question goes through the handler rather than reading a package variable. ## When to reach for it Guard when the argument is expensive relative to the guard itself: a dump, a marshal, a large string conversion, a computed summary. Do not guard a line that logs two fields you already have in hand — you have added a branch and a handler call to save one small allocation, and you have made the code harder to read. The honest way to settle it in a hot path is a benchmark with `-benchmem`, comparing `allocs/op` with and without the guard, rather than instinct. ## Two misconceptions worth naming The first is that the compiler removes calls below the configured level. It cannot: the level lives in a handler built at run time, possibly from configuration, and the call is a normal method call on an interface-holding struct. The second is that slog evaluates arguments lazily, pulling values only if the record survives. It does not. The value you pass is computed before the callee starts. Deferring the *work* requires either the explicit guard above or passing a value that knows how to produce itself later — and even then the value itself still has to be constructed and boxed at the call site.

  • Does the guard help when the argument is just an int or a short string?
    Rarely. Boxing a small value into an `any` is one small allocation, and the guard itself costs a branch plus a call into the handler's `Enabled`. Use it when the argument is expensive to produce — a dump, a `fmt.Sprintf`, a marshal, a string conversion of a large buffer — not for plain fields you already hold.
  • What exactly does `logger.Enabled(ctx, slog.LevelDebug)` consult?
    It forwards to the handler's `Enabled(ctx, level)` method. For the built-in text and JSON handlers that compares the level against `HandlerOptions.Level`, which defaults to Info when the options are nil. A custom handler can answer per context or per request, which is why the check goes through the handler rather than a package-level variable.
  • Does the same reasoning apply to an Info call when Info is enabled?
    The call-site cost is identical — arguments evaluated, values boxed. The difference is that the record then gets a timestamp, becomes attributes, and is formatted and written, which usually dwarfs the call site. At an enabled level the lever is not a guard but the call form and how many attributes you attach.

saying these in an interview costs you the question

  • Claims the compiler removes log calls below the configured level
  • Thinks slog evaluates arguments lazily, only if the level passes
  • Believes a disabled level makes the call literally free
  • Guards every log call with Enabled, including trivial ones
  • Assumes Enabled reads a package variable instead of asking the handler