skip to content

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

level: juniorimportance: must knowfreq 56%

answer

  1. both are collected, a file is not
  2. one stream, chosen on purpose
  3. severity is a field, not a pipe
  4. the runtime still owns descriptor 2

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.

solid answer

~40 s

Either works, because the runtime captures file descriptors 1 and 2; what matters is that you pick one and never write log records to a file inside the container, where nothing collects them. The common convention is `os.Stdout` for the service's own records and `os.Stderr` for the runtime's output, so you construct the handler explicitly: `slog.New(slog.NewJSONHandler(os.Stdout, nil))`. Avoid splitting records across both by level — they are two independent pipes, ordering between them is not guaranteed, and an error record and the trace that follows it can arrive out of order. Note that you never get os.Stderr entirely to yourself: an unrecovered panic's message and stack, and fatal runtime errors, are written to descriptor 2 by the runtime no matter what your handler does.

go deeper

for a junior

Be ready to say that both streams are collected from a container, that you pick one and stay on it, and that writing to a file inside the container is the mistake to avoid.

for a middle

Explain why severity belongs in the record rather than in the choice of pipe, and that the two streams are separate pipes whose relative ordering the collector cannot guarantee.

for a senior

Show that you know the runtime writes panics and fatal errors to descriptor 2 behind your handler, so the platform must collect both streams and part of your worst output is never structured.

for a principal

Own the fleet-wide call: one stream and one format is a contract with the collector, and letting each service choose pushes the cost of parsing and correlating onto the platform.

## The question behind the question A Go service does not decide where its logs are *stored*. It decides which file descriptor its records leave the process on. Everything after that — collection, retention, search — belongs to whatever supervises the process. So "stdout or stderr" is really "which of the two descriptors the platform already reads do I hand my records to, and do I hand them to only one?" ## What the container runtime actually reads When a container starts, the runtime attaches pipes to descriptor 1 (`os.Stdout`) and descriptor 2 (`os.Stderr`) and copies whatever appears there into its log store, usually tagging each line with which stream it came from and a timestamp. Nothing else in the container is collected by default. A file the process opens itself lives in the container's writable layer: no collector sees it, nothing rotates it, and it disappears when the container is replaced. That makes "write to a file" the actual wrong answer here — the stdout/stderr choice is a detail by comparison. ## Constructing the handler The standard library never guesses. `slog.NewJSONHandler` and `slog.NewTextHandler` both take an `io.Writer` as their first argument, so the destination is whatever you pass: ```go logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) logger.Info("listening", "addr", addr) ``` If you never construct a handler at all, Go's built-in default logging goes to standard error. That is the historical default of the `log` package, and it is why a program that has never been configured still prints diagnostics on descriptor 2. ## Why one stream, not two Splitting by severity — info to stdout, errors to stderr — looks tidy and causes two problems. First, **ordering**. The two pipes are independent. The runtime reads both and interleaves them by arrival, so a record that says "failed to write row 4001" and the record that immediately preceded it may appear out of order, or in two different files if the platform separates streams. During an incident that reordering is exactly the thing that costs you time. Second, **parsing**. Everything downstream now needs configuring for two sources with the same format, and any tool that only follows one of them silently loses half the story. Severity is a *field in the record*, not a choice of pipe. A structured handler already writes `"level":"ERROR"`; the collector can filter on it. ## What still lands on stderr regardless Even a service that routes every record to `os.Stdout` does not own `os.Stderr`. The Go runtime writes directly to descriptor 2, bypassing your handler completely, for: - an unrecovered panic — the message plus the goroutine's stack trace, after which the process exits with status 2; - fatal runtime errors such as a concurrent map write or the "all goroutines are asleep - deadlock!" report, which print a full goroutine dump; - anything a dependency writes through the default logger without asking you. So the platform must collect both streams, and stderr will contain unstructured, multi-line text your JSON parser cannot handle. This is normal and worth knowing before an incident: the most important output your service ever produces — its crash — is not structured and did not go through your handler. ## Choosing between the two Given both are collected, the tiebreakers are conventions: - **os.Stdout** is what the "logs are an event stream" convention prescribes, and it keeps your structured records physically separate from the runtime's unstructured crash output. This is the common choice for services. - **os.Stderr** is Go's own historical default and the UNIX convention for diagnostics, and it is the right answer for a *command-line tool*, where stdout carries the program's actual result and must stay parseable by a pipeline. The distinction is what the program's output *is*. A service's log records are its output; a CLI's log records are noise next to its output. Neither costs more than the other: both are `*os.File`, and a write to either is one system call of the same size. ## The rule to state in an interview One stream, chosen deliberately; every record on it; the same format on every line; never a file inside the container; and know that the runtime will still write crashes to stderr behind your back.

  • Why is splitting info records to os.Stdout and error records to os.Stderr usually a bad idea?
    They are two independent pipes. The collector merges them by arrival, so ordering between the streams is not guaranteed and an error record can surface before the record that explains it, or land in a separate file entirely. It also doubles the parsing configuration downstream. Severity belongs in the record as a field, which a structured handler already writes.
  • What still reaches os.Stderr after you route every log record to os.Stdout?
    The runtime's own output. An unrecovered panic's message and stack trace, fatal errors such as a concurrent map write or the deadlock report, and any dependency using the default logger all go to descriptor 2 without passing through your handler. The platform must collect both streams, and that content is unstructured multi-line text.
  • Why shouldn't the service write its records to a file inside the container instead?
    Nothing collects it. The file sits in the container's writable layer where no collector looks, nothing caps its size, and it vanishes when the container is replaced. You have also made a retention decision inside the binary that the platform cannot see or change. The process's responsibility ends at the file descriptor.

Stdout and stderr are two conveyor belts leaving the factory, and both feed the same sorting hall. Putting half the parcels on each belt does not sort them for you; it just means they arrive shuffled.

saying these in an interview costs you the question

  • Believes stderr is only for errors and stdout only for info
  • Writes log records to a file so they survive a restart
  • Assumes the collector merges the two streams in order
  • Claims writing to os.Stderr is slower than os.Stdout
  • Thinks routing records to stdout stops panics appearing on stderr