o'ailly The Four Questions

Chapter 3 — The Commentary Channel

Draft status: author draft; human verification pending. Every runnable listing was executed by the author during writing in a scratch directory the listing itself creates; printed outputs are real transcripts.

Misnamed from birth

The second question of the routine — what did stderr say? — is about the most consistently misread channel in the transcript, and the misreading starts with the name. “Standard error” suggests a stream that carries errors, implying both that everything on it is an error and that errors are all it carries. Neither is true, and a reader holding either half of the name’s implication will misjudge transcripts weekly. The design intent, older than most tools you will read, is better captured by a different name: stderr is the commentary channel — the stream a process uses to talk about its work, kept separate from stdout precisely so that the work’s product stays clean enough for the next program to consume. Data on one channel, narration on the other. Diagnoses go there, yes; so do warnings, progress reports, deprecation notices, usage help, debug chatter, and the occasional banner printed for no reason anyone remembers. A process writing to stderr is not necessarily failing. It is talking to you — the supervising reader — rather than to the pipeline, and question two exists because what it says there routinely changes the verdict on claims the status and stdout would happily support.

The first discipline is structural, before any line is interpreted: know that the two streams exist, and know what the transcript you are holding did with them. Every transcript is downstream of a capture decision — merged, split, or partially discarded — and that decision determines what silence means, the way a courtroom transcript’s meaning depends on whether the microphone was on.

mkdir work && cd work
cat > convert.sh <<'SCRIPT'
#!/bin/sh
echo "warning: input has no header row" >&2
echo "converted 14 records"
SCRIPT
chmod +x convert.sh
./convert.sh 2>/dev/null
echo "--- without the commentary channel: the line above is all you see"
./convert.sh 2>&1
echo "exit: $?"
converted 14 records
--- without the commentary channel: the line above is all you see
warning: input has no header row
converted 14 records
exit: 0

One program, run twice. In the first run its commentary was discarded, and the record shows a clean conversion: fourteen records, nothing else worth saying. In the second, the same program, same input, same exit 0 — and a warning that the input had no header row, which for a record-conversion task is the kind of fact that decides whether those fourteen records are fourteen truths or thirteen truths and a column header eaten as data. Now place the claim this chapter’s family is built on: “the conversion ran cleanly, with no warnings.” Against the merged transcript, contradicted — the warning is right there, and “ran cleanly” in the claimant’s sense is false even though the run succeeded in the tool’s sense. Against the first transcript, the honest verdict is insufficient, not supported — and this is the structural point. The first transcript does not show an absence of warnings; it shows an absence of evidence about warnings, because the channel warnings travel on was pointed at /dev/null. A no-warnings claim can only ever be supported by a transcript whose capture provably included the commentary channel. When the redirection is visible in the transcript, as here, you can read the capture decision straight off the command line — one more reason the command lines belong in transcripts. When it is not visible, the capture configuration is an assumption, and your confidence should price it.

A taxonomy for the talk

Once captured, commentary has to be classified, because its species carry different evidential weight and readers who treat the channel as homogeneous either panic at noise or sleep through signal. Five species cover nearly everything. Diagnoses — “No such file or directory,” “Permission denied,” “connection refused” — are testimony that a specific operation failed; they usually travel with a nonzero status, and when they do, they tell you which failure the number summarizes; chapter 1’s transcripts used them this way throughout. Warnings are the tool saying it proceeded, but under protest: an assumption was made, a fallback taken, an input odd. A warning does not contradict “it succeeded”; it contradicts “it succeeded and nothing was unusual” — and it predicts. Today’s “input has no header row” is next week’s corrupted import; deprecation warnings are the calendar of future breakage. In judgment terms, warnings rarely flip supported to contradicted on their own, but they cap confidence and they belong in any faithful summary. A reader that reports success and omits the warning has not summarized the transcript; it has improved it, which is not the job. Progress — “fetching 2/3…” — is narration for humans watching in real time, evidence only of liveness, and the species most safely skimmed; its one judgment-relevant property is completeness, a 2/3 with no 3/3 being a story that stops mid-sentence. Notices — informational lines, version banners, “using config at PATH” — are context; occasionally decisive context, as when the config path in the banner is not the config the claim assumes. And debug chatter is everything the producer forgot to turn off; it means nothing, except that its sheer volume can bury the one diagnosis that means everything, which is why question two is what did stderr say and not did stderr say anything. Volume is not verdict. One warning in ten thousand progress lines still predicts; ten thousand lines that are all progress still amount to a clean run.

Diagnoses without defeat

The taxonomy’s first species needs one complication before the worked transcripts, because it produces this family’s inverse misreading. The straightforward case pairs a diagnosis with a nonzero exit: one failure, told twice, in number and in prose. But diagnoses also appear in transcripts that end 0, and the pessimist’s reflex — there is an error line, therefore the run failed — is as wrong as the optimist’s, and in practice almost as common. Three mechanisms put a true diagnosis inside a successful run, and each changes what the line testifies to. First, recovery: retrying tools narrate the attempts that failed — a “connection refused” followed, three lines later, by a completed transfer is the biography of a retry loop, and the diagnosis is evidence that an attempt failed once, not that the work did. The verdict discipline is temporal: a diagnosis testifies about the moment it describes, and later lines can overtake it. Second, tolerated casualties: chapter 2’s swallowing aggregates look exactly like this from the reader’s side — a loop’s || arm or a tool’s keep-going flag lets member failures print their diagnoses while the aggregate exits clean; there the diagnosis is not overtaken but absorbed, the failure is real and unrepaired, and the clean exit merely means nobody propagated it. Third, borrowed voices: a parent process relays or triggers a child’s complaint and then proceeds — the child’s stderr lands in the parent’s transcript, attributed by nothing but position. Telling recovery from absorption from relay decides verdicts: “the transfer completed” survives all three; “every input was processed” survives only the first; and nothing in the exit status distinguishes them. Only the content — did a later line report the failed thing done? does a count reconcile? does the diagnosis name a member or the whole? — separates a run that healed from a run that limped, and a reader who cannot say which it was should say so in the verdict.

Commentary beside partial results

The channel’s highest-value moments are the ones where it disagrees with the other evidence in the transcript — where stdout shows product and stderr shows trouble, and the reader must hold both:

mkdir work && cd work
printf "level = ERROR\n" > a.conf
grep -n "ERROR" a.conf missing.conf
echo "exit: $?"
a.conf:1:level = ERROR
grep: missing.conf: No such file or directory
exit: 2

Chapter 1 met grep’s trichotomy; here is the case its tidy table omitted. This search found its pattern — the hit is printed, labeled with file and line — and also failed, because its second input does not exist, and grep’s contract resolves the collision in favor of reporting the error: exit 2. Three readings now offer themselves, and two are wrong. The pessimist reads exit 2, declares the command failed, and discards the printed hit — but the hit is real; results already printed do not evaporate because a later input broke. The optimist reads the hit, declares the search successful, and glosses the stderr line — but then the claim “no other file contains ERROR” inherits a hole the size of missing.conf, which was never searched. The reader this book is training holds both: the findings are valid, the coverage is not — the search answered for a.conf and never ran for the rest. Concretely: claim “a.conf sets the level to ERROR” — supported, by the labeled hit. Claim “the ERROR setting appears in exactly one of the two configs” — insufficient, because the second config was never read, and the stderr line is the proof. Partial success is not a middle verdict; it is a split verdict, different claims about the same transcript landing differently, and the commentary channel is what tells you where to draw the line.

Separation as a gift, and split captures

When you meet a producer who uses the channels as designed, the reading gets easier — and the capture question gets sharper:

mkdir work && cd work
cat > fetch.sh <<'SCRIPT'
#!/bin/sh
echo "fetching 1/3..." >&2
echo "fetching 2/3..." >&2
echo "fetching 3/3..." >&2
echo '{"status": "complete", "items": 3}'
SCRIPT
chmod +x fetch.sh
./fetch.sh > result.json 2> progress.log
echo "exit: $?"
cat result.json
cat progress.log
exit: 0
{"status": "complete", "items": 3}
fetching 1/3...
fetching 2/3...
fetching 3/3...

A well-mannered tool: product on stdout — clean JSON, parseable by the next program without a single narration line to strip — commentary on stderr, and the operator captured each to its own file. This is the separation working as the designers of the convention intended, and it is why the convention exists at all: had the progress lines gone to stdout, result.json would be three lines of chatter followed by JSON, and every downstream consumer would need to know it. The split-capture lesson cuts the other way, though, and it is the one you will need as a reader: when streams are captured separately, each file is a partial transcript, complete only for its own channel, and claims about “the whole run” need both files plus the knowledge that they are both files. A reader handed result.json alone would see a flawless run — and would see exactly the same flawless run if the fetches had printed three warnings apiece. A reader handed progress.log alone would see three fetches begin and never learn the outcome. Each file is true; neither is the run; and nothing inside either file announces that a sibling exists. That last property is what makes split captures dangerous to judge: a partial transcript does not look partial. The capture commands here disclose the split — the two redirections sit in the listing — but transcripts arrive constantly as bare pasted output, provenance untold. Ask of every transcript: is this the merged record, one stream of a split record, or a stream with its sibling discarded? The same lines support different verdicts under each answer.

Results on the wrong channel

Like the exit convention of chapter 1, the routing convention has its apostates, and they complete the argument for classifying lines by content rather than by the channel they arrived on. The classic is the shell’s own time: ask it to measure a command and the measurement — the entire point of the invocation — is written to stderr, precisely so that the timed command’s stdout stays untouched for the pipeline. A transcript captured stdout-only shows the command’s work and no timing at all; the result of the measurement lives on the commentary channel, and a reader who filed stderr under “noise” has thrown away the answer. Interactive prompts and password requests conventionally go to stderr for the same keep-stdout-clean reason; so do the progress meters of transfer tools; so does --help output in some tools and stdout in others, a notoriously settled-nowhere convention; and diagnostic-leaning tools — linters, compilers, validators — split their findings between the channels in ways that only their documentation records. The mirror apostasy also exists: plenty of software prints “ERROR” lines to stdout because its authors never routed anything anywhere, and log files re-emitted through cat carry their severities wherever the original logger put them. The rule that survives contact with all of this: the channel a line arrived on is a prior about its species, not a classification. A measurement on stderr is still a result; an “ERROR:” in stdout is still a diagnosis; the species is in the content, and the channel merely tells you who the line was addressed to — the pipeline, or you.

What order testifies to

Merged capture solves the completeness problem and creates a subtler one. The following transcript was produced by a four-line program whose lines were emitted in the order 1, note, 2, 3:

mkdir work && cd work
cat > steps.py <<'SCRIPT'
import sys
print("step 1 done")
print("note: step 2 used the fallback path", file=sys.stderr)
print("step 2 done")
print("step 3 done")
SCRIPT
python3 steps.py 2>&1 | cat
note: step 2 used the fallback path
step 1 done
step 2 done
step 3 done

The note about step 2 appears before step 1. No time machine is involved — buffering is. By longstanding C-library convention (see setvbuf(3)), stderr is unbuffered or line-buffered — its lines leave the process promptly — while stdout, when it feeds a pipe rather than a terminal, is block-buffered: lines accumulate in a buffer and land wholesale when it flushes, here at exit. So the three stdout lines arrived together, late, and the prompt stderr line beat them all. The merged transcript’s order is the order of arrival at the capture point, not the order of emission, and the two agree only within a single channel. Across channels, order testifies to almost nothing.

One precondition, because this demonstration is language-specific and the book preaches pinning conditions. The setvbuf(3) rule describes C stdio; the python3 steps.py 2>&1 | cat listing above interleaves the way it does only under a default CPython pipe, whose print() is interpreter-buffered rather than governed directly by setvbuf. Run the same program with python3 -u or PYTHONUNBUFFERED=1 and stdout is line-buffered, the block no longer lands wholesale at exit, and the interleaving changes — so read the demonstration as being about a block-buffered runtime, not about Python as such. When a claim turns on stream order, the buffering mode of the producing runtime is part of the shape, not a detail beneath it.

The misreading this breeds is causal narration: a reader sees the note first and reports “the run began by falling back, then proceeded through its steps” — a story the transcript’s layout suggests and its facts do not. In real incident transcripts the stakes are higher: the error line that appears “before” the request it belongs to, the warning that seems to precede the command that caused it, the interleaved output of two parallel jobs (the previous trilogy’s parallel chapters produced exactly such transcripts) where adjacency implies relationship and implies it falsely. The discipline: within one stream, order is evidence; across merged streams, order is an artifact of buffering until proven otherwise; and attribution — which line belongs to which command, which job, which channel — must rest on the lines’ content and labels, never on their neighborhood. Producers who tag their lines ([job-3], timestamps, the labeled file:line: prefixes grep printed earlier) are handing you attribution; transcripts without tags leave attribution a matter of inference, and inferences from adjacency are the weakest kind. When a verdict turns on which command produced this line and only position answers, the verdict is leaning on air, and it should be priced as such.

Attribution deserves its own worked judgment, because it is where merged transcripts do their quietest damage. Picture the commonest shape in agent work: a transcript containing three commands run in sequence, each followed by its output, streams merged throughout, and somewhere in the middle a bare line reading warning: lock held, waiting. Which command does it belong to? The reader’s instinct says “the one whose output it sits inside” — and within a single-process, single-stream stretch that instinct is sound, because a foreground shell finishes one command before starting the next, so vertical position between two command lines genuinely brackets a command’s output. The instinct breaks exactly when the assumptions behind it break: a background job launched earlier is still writing, and its lines land wherever the capture happened to be; a buffered stdout from command one flushes during command two; a shared log is being tailed alongside live output. Each of these plants lines inside the wrong bracket, wearing the right position. The tells are content-shaped, not position-shaped: a line whose subject matter belongs to an earlier command; a prefix or format matching a different tool’s voice; timestamps, when you are lucky enough to have them, that disagree with the bracket. The discipline extends the section’s rule one step: position attributes a line only when the transcript’s production model — one foreground process, one stream, no stragglers — is itself in evidence, and a claim that hangs on attributing one unlabeled line should say so out loud. The strongest producers make attribution trivial by prefixing; the strongest readers notice when it is not trivial and refuse to pretend otherwise.

The deprecation clock

One species deserves a closing note at a longer horizon, because its misreading is not a wrong verdict on one transcript but a wrong posture across hundreds. Deprecation warnings are unique among commentary in that they are scheduled: each one is a vendor’s announcement that a behavior the run depends on has an expiry date, which makes it the only line in a transcript that testifies about a future run rather than this one. Judged locally, it changes little — the run succeeded; “ran without warnings” is contradicted; confidence in “this will keep working” should dip. Judged as a series, it changes everything: the same warning recurring across a week of transcripts is a countdown observed at intervals, and the correct summary of such a series is not “all runs succeeded” but “all runs succeeded on borrowed time, and here is the borrowed thing.” Readers positioned to see many transcripts — reviewing a CI history, auditing a fleet’s logs, summarizing a batch — are the only ones who can read this clock, and the reading is cheap: recurrence plus content plus the vendor’s stated timeline. The failure mode is treating each occurrence as independently negligible, which each one is; negligible-every-time is how scheduled breakage arrives on schedule, surprising no one who read the commentary and everyone who filtered it. The same series-level reading applies to any warning that names a threshold — “disk 87% full” rising across transcripts is a trajectory, and trajectory is evidence no single transcript contains. Where chapter 7 takes up time inside one transcript, this is time across them: the commentary channel is the only channel that routinely talks about it, which is one more reason the reader who skips stderr is not skimming noise but discarding the only forward-looking testimony the record has.

Question two, as a routine

The chapter’s practice, in the order the questions should be asked. Find the commentary: is stderr in this transcript at all — merged in, split into its own record, or discarded by a visible (or worse, invisible) redirection? No commentary captured means no-warnings claims cap at insufficient, however clean the rest looks. Classify each line by species — diagnosis, warning, progress, notice, debug — and bind it to the command it narrates, using labels and content rather than adjacency. Let diagnoses explain statuses: a nonzero exit plus its stderr line is one fact told twice, and the telling with detail outranks the number. Let warnings modulate, not veto: they contradict “nothing unusual,” they survive “it succeeded,” they cap confidence, and they must survive into your summary. Treat progress as liveness only, notice its truncation if it stops mid-count, and refuse to let its volume drown a single line of higher species. And hold partial-success transcripts to split verdicts: findings printed before a failure are findings; coverage after a failure is a claim the transcript no longer supports.

What the commentary channel cannot do is speak when nothing was said. The transcript with no stderr lines, no diagnoses, no warnings — and no output at all — is the hardest text in this book, misread more confidently than any other, and it gets the next chapter to itself: the five meanings of silence, and how to tell which one you are hearing.

1 / 1