skip to content

Handlers and Log Output

Everything wired around a slog.Handler: one you wrote yourself, ReplaceAttr and LogValuer rewriting fields, Enabled skipping work, and the old log package folded into the same stream.

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

explore

questions

23

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
open as a page

Which four methods does slog.Handler require, and what is each one for?

level: juniorimportance: must knowfreq 50%

basics

~20 s

slog.Handler has four methods: Enabled reports whether a level should be logged, Handle formats and writes one Record, WithAttrs returns a new handler carrying extra attributes, and WithGroup returns one that nests later keys under a name.

open as a page

What does Go's log package write by default: which stream, what prefix, and what severity?

level: juniorimportance: must knowfreq 48%

basics

~20 s

Go's log package writes to standard error through the one logger returned by log.Default(), prefixing each line with the date and time (log.LstdFlags). It has no severity levels: log.Print, log.Printf and log.Println all produce identical, unlabelled lines.

open as a page

In Go's log/slog, what does implementing the LogValuer interface on a type change about how it is logged?

level: juniorimportance: must knowfreq 38%

basics

~20 s

A type that implements LogValuer supplies a LogValue method returning a slog.Value, and slog logs that substitute instead of the value itself. Every handler sees the substitute, so a secret can be replaced with a fixed placeholder in one place.

open as a page

Should a containerised Go service's slog handler write to os.Stdout or os.Stderr?

level: juniorimportance: must knowfreq 56%

basics

~20 s

Both streams are collected by the container runtime, so the real rule is to pick one and send every record there. Most teams choose os.Stdout for the application's log stream and leave os.Stderr to the runtime's own panic output.

open as a page

A scheduled Go container job buffers its slog output and calls log.Fatal on failure. Why is the tail of its log missing?

level: seniorimportance: must knowfreq 47%

basics

~20 s

log.Fatal ends the process through os.Exit, which runs no deferred functions, so the deferred Flush on the buffered writer never happens and every record still in the buffer is discarded. Stop buffering the log sink, or flush before exiting.

open as a page

Why does slog's `LogAttrs` allocate less than `Logger.Info` with key/value arguments?

level: middleimportance: should knowfreq 42%

basics

~20 s

LogAttrs takes typed slog.Attr values, so each string, number or duration travels inside an Attr without becoming an interface. The variadic form takes any, which boxes each value on the heap and makes slog re-derive the pairs at run time.

open as a page

A custom slog.Handler writes to one shared io.Writer; how do you keep concurrent Handle calls from interleaving?

level: middleimportance: should knowfreq 42%

basics

~10 s

Format the whole line into a local byte slice, then lock a mutex and issue exactly one Write. Store that mutex as a pointer so every handler derived by WithAttrs shares it.

open as a page

What does testing/slogtest.TestHandler check that your own handler tests usually miss?

level: middleimportance: should knowfreq 33%

basics

~20 s

testing/slogtest.TestHandler drives a handler through the awkward corners of the slog.Handler contract - a zero record time, an empty attribute, a group with no attributes, a group with an empty key - and checks the output you parse back.

open as a page

After slog.SetDefault, what happens to a dependency's log.Printf calls and at what level do they arrive?

level: middleimportance: should knowfreq 40%

basics

~20 s

slog.SetDefault redirects the log package's shared logger into the new default logger's handler, so log.Printf lines become slog records. Each arrives as a plain message with no attributes, at the level slog.SetLogLoggerLevel selects, which is Info unless changed.

open as a page

How does HandlerOptions.ReplaceAttr let one slog handler redact an attribute anywhere in a record?

level: middleimportance: should knowfreq 34%

basics

~20 s

ReplaceAttr is a function on slog.HandlerOptions that the handler calls for every non-group attribute it writes. It receives the enclosing group keys and the attribute, and returns a replacement; returning the zero slog.Attr drops the attribute entirely.

open as a page

In log/slog, why does a String method on a token type fail to keep the token out of JSON output?

level: middleimportance: should knowfreq 30%

basics

~20 s

A String method satisfies fmt.Stringer, which only the fmt package consults. slog's JSON handler encodes an unknown value with encoding/json, which looks for MarshalJSON and struct tags and never calls String, so the real value is written.

open as a page

What does wrapping an slog handler's writer in a bufio.Writer buy, and what does it cost?

level: middleimportance: should knowfreq 41%

basics

~20 s

It buys fewer write system calls, because records are batched instead of written one at a time. It costs you every record still sitting in the buffer when the process ends without a flush, plus delayed visibility while you wait.

open as a page

A Go pipeline stage logging with slog allocates six times per record at 40k records/s. Where do you look, and what do you change first?

level: seniorimportance: should knowfreq 34%

basics

~20 s

Benchmark the log call on its own with -benchmem to attribute the allocations, then move the constant fields onto a With-derived logger built once, switch the per-record call to LogAttrs with typed constructors, and stop attaching the raw payload buffer.

open as a page

Why can WithAttrs in a custom slog.Handler let two derived loggers overwrite each other's attributes?

level: seniorimportance: should knowfreq 27%

basics

~10 s

append reuses spare capacity, so a WithAttrs that does append(h.attrs, new...) lets two children write into the same backing array slot; the second overwrites the first. Clip or copy the parent's slice first.

open as a page

http.Server.ErrorLog is nil in a reverse proxy and its TLS handshake errors never reach the structured sink. Why, and how do you fix it?

level: seniorimportance: should knowfreq 34%

basics

~20 s

With a nil ErrorLog, net/http writes its own diagnostics through the log package's standard logger, as plain text on standard error. A JSON-only ingest discards them. Set ErrorLog to a logger from slog.NewLogLogger so those lines become structured records.

open as a page

With slog.Any on a whole Customer struct, why does an API token field still print in full when its type has a LogValue method?

level: seniorimportance: should knowfreq 26%

basics

~20 s

slog resolves the attribute's own value, not the fields inside it. The struct is not a LogValuer, so the handler's encoder walks its fields directly and never calls the token type's LogValue. Put LogValue on the struct.

open as a page

Which parts of a Go service's log output should a platform owner mandate fleet-wide, and which stay a team's call?

level: principalimportance: should knowfreq 30%

basics

~20 s

Mandate only what the collector depends on: one output stream, one wire format, no log files inside the container, and no buffering of the sink without an exemption. Leave levels, record contents, sampling and handler implementation to teams.

open as a page

What does slog's `Logger.With` save at log time compared with repeating those attributes per call?

level: middleimportance: nice to knowfreq 28%

basics

~20 s

With returns a logger whose handler already holds those attributes. The built-in text and JSON handlers serialise them once, then copy the finished bytes into every record, so those keys and values are never formatted again.

open as a page

Why do Go's log and slog packages offer no file rotation, and what should a service do instead?

level: middleimportance: nice to knowfreq 33%

basics

~20 s

Their whole contract is an io.Writer, so they never learn whether the destination is a file and have nothing to cap, roll or compress. A service writes to a process stream and lets its supervisor own retention.

open as a page

Your team publishes a slog.Handler other teams import. What do you freeze in its contract before tagging?

level: principalimportance: nice to knowfreq 20%

basics

~10 s

Freeze the behaviour importers cannot see in a signature: the output field names and framing, whether Handle blocks or drops when the sink stalls, what Enabled promises, and what a silent write failure does.

open as a page

Bridging every legacy log.Printf into slog converts a codebase at once but yields one level and no attributes. How do you decide bridge versus rewrite?

level: principalimportance: nice to knowfreq 22%

basics

~20 s

Bridge first, because it captures code you cannot edit and stops diagnostics being lost today. Then rewrite only the call sites whose fields someone actually queries, and agree with the log consumers how long unparsed bridged text is acceptable.

open as a page

Should redaction be enforced by LogValuer on the domain types or by ReplaceAttr in one handler?

level: principalimportance: nice to knowfreq 20%

basics

~20 s

Both, in a fixed order. LogValuer on the domain types is deep but narrow: it covers only types you own and route through slog. ReplaceAttr in one handler is broad but shallow, reaching every record including unreviewed code.

open as a page