skip to content

When would you use slog.NewMultiHandler, and what decides whether a record reaches every handler you gave it?

level: middleimportance: nice to knowfreq 26%

answer

  1. one call, two audiences
  2. a handler that holds other handlers
  3. the wrapper says yes if anyone says yes
  4. each branch still filters on its own
  5. two branches means two encodings per record

basics

~10 s

It fans one record out to several handlers, so one slog call can produce readable text on a terminal and JSON for a collector. Each wrapped handler still decides for itself whether it writes.

solid answer

~50 s

`slog.NewMultiHandler` wraps several `slog.Handler` values and forwards each record to all of them, which is how you get one log call to land in two places with different formats — a text handler on the terminal for the operator and a JSON handler on a file or pipe for the collector — without writing the call twice. The important semantics are that the wrapper is enabled if *any* wrapped handler is enabled, and that every wrapped handler still applies its own filtering when it receives the record. So passing the multi handler's check does not mean both destinations wrote the record: a branch configured with a stricter minimum level silently keeps nothing, and to someone reading only that destination the record simply does not exist. Also remember the cost: the record is formatted once per branch, so N branches means N encodings and N writes.

code

go · 10 lines
go
f, err := os.Create("/var/log/app.json")
if err != nil {
	return err
}
h := slog.NewMultiHandler(
	slog.NewTextHandler(os.Stderr, nil),
	slog.NewJSONHandler(f, nil),
)
slog.SetDefault(slog.New(h))
slog.Info("listening", "addr", ":8080") // written twice, two formats

go deeper

for a junior

Know that several slog handlers can be composed behind one handler so a single log call reaches more than one destination, and that this is how a program gets readable terminal output and structured file output at the same time.

for a middle

Explain the composition rules: the wrapper is enabled if any child is, each child still filters and formats independently, and derived attributes must be pushed into every child because handlers are immutable.

for a senior

Be able to diagnose the asymmetry — a record present in one destination and missing from another because that branch filters more strictly, or because a hand-rolled wrapper dropped attributes on one branch. Mention the doubled formatting cost and synchronous write behaviour.

for a principal

Decide whether fan-out belongs in the process at all, given that each branch is a failure mode and a cost multiplier; often the better answer is one stream and a consumer that filters, with fan-out reserved for genuinely different audiences.

## The problem it solves You want one destination that a person reads and one that a machine reads. A `slog.TextHandler` on the terminal is pleasant to watch during a deploy; a `slog.JSONHandler` on a separate stream is what gets ingested and queried. Without composition you either pick one and annoy the other audience, or you log everything twice at the call site — which drifts within a week, because someone always updates one of the two calls. `slog.NewMultiHandler` composes handlers instead: it takes several `slog.Handler` values and returns one, and every record it is given is forwarded to all of them. ```go h := slog.NewMultiHandler( slog.NewTextHandler(os.Stderr, nil), slog.NewJSONHandler(logFile, nil), ) slog.SetDefault(slog.New(h)) ``` One `slog.Info` call at the call site; two renderings, in two places, in two formats. ## The semantics that catch people **Enabled is a disjunction.** `slog.Handler` has an `Enabled(ctx, level) bool` method that lets a caller skip expensive work when nothing would consume the record. A fan-out handler cannot answer that question with a single yes/no of its own — it reports enabled if *any* of its children would take the record, because dropping early would starve the children that wanted it. **Each child filters again.** That disjunction is exactly why "the record reached the multi handler" does not mean "the record reached every destination". If the terminal branch is configured to take everything and the file branch is configured with a stricter minimum level, the multi handler happily accepts a debug record, hands it to both, and only one of them writes it. Nothing logs a warning about this; the branch just quietly has fewer records than the other. When someone says "this line is in the container output but not in the collector", asymmetric filtering across branches is the first thing to check — followed by whether the two branches even have the same writer lifetime. **Attributes fan out too.** Handlers are immutable and derive new handlers: `WithAttrs` and `WithGroup` return a *new* handler carrying extra context. A fan-out handler has to push those down into every child and rebuild itself, so a logger derived with `Logger.With(...)` keeps working across all branches. What it cannot do is give one branch different attributes from another. **Cost multiplies.** Each child formats the record independently — its own buffer, its own encoding, its own `Write`. Two handlers means twice the formatting work and two syscalls per record. That is usually fine and occasionally not, and it is the reason a fan-out is a deliberate choice rather than a default. **Ordering and failure.** Children are called in the order given. `Handle` returns an `error`, and the `Logger` convenience methods discard it, so a child that fails to write is not going to make itself known at the call site. If one destination is a file that filled up, the other destination is where you will find out — assuming the other destination is still healthy. ## Before there was one in the standard library Fan-out is only about fifteen lines: a type holding `[]slog.Handler` whose `Enabled` ORs the children, whose `Handle` loops over them, and whose `WithAttrs`/`WithGroup` rebuild the slice. Plenty of codebases have exactly that, written before the standard library grew a constructor for it. If you inherit one, the review question is whether it gets the `Enabled` disjunction and the `WithAttrs` fan-out right — those are the two parts hand-rolled versions most often get wrong, and the symptom of the second one is attributes from `With` silently missing on one branch. ## When not to reach for it If both branches write the *same* format to the *same* stream, you do not want a fan-out, you want one handler — duplicated records are worse than none. If one branch is really "the same records, filtered differently", consider whether the consumer can filter instead: a stream you can query is easier to change than a filtering decision compiled into a binary. And if the second destination is a network service rather than a file, the interesting question stops being fan-out and becomes what happens when that destination is slow, which a synchronous handler answers by blocking the goroutine that logged.

  • Why can a fan-out handler not just report Enabled from its first child?
    Because a false from that child would suppress the record for every other child too. The wrapper has no single answer of its own, so it reports enabled when any child would take the record and lets each child re-apply its own filtering when the record actually arrives. The cost of that is a record occasionally being prepared for branches that then discard it.
  • A logger derived with Logger.With adds a request id, but only the JSON branch shows it. What went wrong?
    The fan-out handler's `WithAttrs` did not push the attributes into every child and rebuild itself — a classic bug in a hand-rolled version, where one branch is kept as the original handler. Handlers are immutable and derive new handlers, so a wrapper must derive each child and construct a new wrapper around the results.
  • What does fanning out cost compared with a single handler?
    Each child formats the record independently, so two branches mean two encodings, two buffers and two writes per record. On a hot path that is measurable. It is also two failure modes: a slow or blocked destination stalls the goroutine that called Info, because handlers are invoked synchronously on the logging goroutine.

It is a mailing list, not a broadcast. The list accepts your message if at least one subscriber is interested, and each subscriber still has their own filter deciding whether it lands in their inbox.

saying these in an interview costs you the question

  • Thinks calling slog.SetDefault twice fans out to both handlers
  • Assumes a record accepted by the wrapper reaches every branch
  • Forgets that each branch encodes the record separately
  • Points two branches at the same stream and duplicates records
  • Expects a failing branch to surface an error at the call site