skip to content

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

level: juniorimportance: must knowfreq 50%

answer

  1. four methods, nothing more
  2. one decides, one writes, two derive
  3. Logger.With delegates to one of the derivers
  4. the cheap level check comes first

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.

solid answer

~40 s

`slog.Handler` is a four-method interface. `Enabled(ctx, level) bool` is the cheap gate: the logger calls it before it even builds a record, so a suppressed debug call costs almost nothing. `Handle(ctx, Record) error` gets one finished record — time, level, message, program counter and attributes — and is where you format the line and write it; the contract says it is only called when `Enabled` returned true. `WithAttrs(attrs []slog.Attr) Handler` returns a *new* handler carrying the receiver's attributes plus these; that is what `Logger.With` calls underneath. `WithGroup(name string) Handler` returns a new handler that qualifies every later key under that group name, and by convention returns the receiver unchanged when the name is empty. The last two must never mutate the receiver: the parent logger has to keep working exactly as before.

code

go · 6 lines
go
type Handler interface {
	Enabled(context.Context, Level) bool
	Handle(context.Context, Record) error
	WithAttrs(attrs []Attr) Handler
	WithGroup(name string) Handler
}

go deeper

for a junior

Be ready to name all four methods and say in one line what each does. Knowing that Handle is the only one that writes, and that the other two derivation methods hand back a new handler, is the bar here.

for a middle

Expect to explain why Enabled exists separately from Handle, and what the record carries: time, level, message, program counter and attributes walked with Record.Attrs.

for a senior

Show that you know the contract rules an output-producing Handle owes — zero time omitted, empty groups dropped, empty-key groups inlined — and that handlers must tolerate concurrent calls.

for a principal

Be able to argue where a wrapping handler belongs versus a new implementation, and what a small four-method interface buys a platform team that has to compose filtering, enrichment and fan-out.

## The split slog draws The `log/slog` package has two halves. `slog.Logger` is the front end you call — `Info`, `Error`, `With`, `LogAttrs` — and it does almost nothing itself: it assembles a `slog.Record` and hands it to a back end. The back end is a `slog.Handler`, and that is the piece you implement when you want your own output format, your own sink, or your own filtering. Everything the standard text and JSON handlers do, they do behind this same interface. ```go type Handler interface { Enabled(context.Context, Level) bool Handle(context.Context, Record) error WithAttrs(attrs []Attr) Handler WithGroup(name string) Handler } ``` Four methods, no more. There is no `Close`, no `Flush`, no `SetLevel` — anything like that is your own addition, not part of what slog knows about. ## Enabled — the gate `Enabled(ctx, level) bool` answers one question: should a record at this level be processed at all? `Logger.Info` calls it before constructing the record, so when it returns false the call collapses to a level comparison and the arguments are never formatted. This is why `Enabled` must be cheap and must not write anything. A typical implementation compares the level against a stored minimum, which may itself be a `slog.LevelVar` so it can be changed at runtime. The `context.Context` is passed so a handler can make the decision per request — for example raising verbosity for a traced request. Note the asymmetry: nothing forces a caller to consult `Enabled`, so a handler that is invoked directly should still behave sensibly, but a handler is entitled to assume that when slog calls `Handle`, the level check already passed. ## Handle — the one that writes `Handle(ctx, r Record) error` receives a complete record. `Record` is a struct with `Time`, `Level`, `Message` and `PC` (the program counter of the call site, used to derive source position), plus attributes you walk with `r.Attrs(func(a slog.Attr) bool { ... })`. Returning false from that callback stops the iteration. The interface documentation spells out rules that output-producing handlers are expected to follow, and they are the ones beginners miss: - if `r.Time` is the zero time, omit the time rather than printing a year-1 timestamp; - if `r.PC` is zero, there is no source location to report; - an attribute whose key and value are both zero is ignored; - a group with an empty key has its attributes inlined at the current level; - a group with no attributes produces no output at all; - attribute values should be resolved before use. Also: cancelling the context should *not* stop the record from being written. Log lines are often exactly what you need to debug a cancellation. A handler must be safe for concurrent use — several goroutines can call `Handle` on the same handler at once. ## WithAttrs and WithGroup — the derivation pair These two exist so that `logger.With("service", "billing")` can be cheap and can pre-format shared context once instead of on every record. `Logger.With` calls `Handler.WithAttrs`; `Logger.WithGroup` calls `Handler.WithGroup`. Both are *constructors*, not mutators. They must return a new handler and leave the receiver untouched, because the original logger is still in use elsewhere — usually held by another package or another goroutine. The idiomatic implementation copies the handler struct by value, adjusts the copy's stored attributes or group path, and returns a pointer to the copy. Anything shared between the parent and the child — the writer, the mutex guarding it, a level variable — is shared deliberately, by pointer. `WithGroup(name)` records that every key produced afterwards, both from later `WithAttrs` calls and from the record's own attributes, is qualified by that name; nested groups nest. When the name is empty the conventional behaviour is to return the receiver, adding nothing. ## The shape a real implementation takes A minimal handler is a struct holding a writer, a pointer to a mutex, a minimum level, the accumulated attributes and the current group path. `Enabled` compares levels; `WithAttrs` and `WithGroup` copy the struct and extend a slice; `Handle` formats a line and writes it under the mutex. That is the whole job — the interface is small on purpose, which is why wrapping handlers (one that filters, one that fans out, one that adds a trace id from the context) are easy to write and compose. ## What people get wrong The two frequent mistakes are implementing only `Handle` and stubbing the other three, and mutating the receiver inside `WithAttrs`. The first means `Logger.With` silently loses attributes; the second means one logger's context leaks into another's output.

  • Who calls Enabled, and may Handle assume it returned true?
    `slog.Logger`'s logging methods call `Handler.Enabled` first and return immediately when it is false, so the record is never built. The interface documentation says `Handle` is only called when `Enabled` returned true, so a handler may skip re-checking the level — though a wrapping handler that calls yours directly is free to break that, which is why the check is cheap enough to repeat if you prefer.
  • Why must WithAttrs return a new handler instead of appending to the receiver?
    Because the receiver is still in use. `base := slog.New(h)` and `req := base.With("req", id)` must be independent: if `WithAttrs` mutated the receiver, every later record from `base` — and from every other logger derived from it — would carry that request id. Returning a new handler is what makes derivation safe to do per request.
  • What should WithGroup do when it is given an empty name?
    Return the receiver unchanged. An empty group name adds no qualification, so allocating a new handler for it is pure waste and can produce a stray empty segment in keys. The standard handlers take this shortcut, and `slogtest` expects the resulting output to look as though the call never happened.

Enabled is the doorman, Handle is the printer, and WithAttrs and WithGroup hand you a fresh pen that already writes your name and section heading on every page.

saying these in an interview costs you the question

  • Says only Handle is required and the rest are optional
  • Mutates the receiver inside WithAttrs and returns it
  • Thinks Enabled formats or writes part of the output
  • Assumes Handle is called even when Enabled returned false
  • Believes a handler may ignore concurrent calls to Handle