One byte is quietly taxing almost every command-line program you run: the newline at the end of a log line. Not because the byte itself is expensive, but because of what your C library or language runtime has promised to do when it sees one. Line buffering turns each completed line into an immediate system call, and when the program prints thousands of lines, that promise turns into thousands of system calls. A recent post by Chris Wellons, writing about his own unzip replacement, makes a sharper claim: we do not have to choose between prompt output and fast output. Flush on a short deadline instead of on a newline, and you get both.
That idea deserves a wider audience than the C systems programmers who already distrust stdio, because the same trade-off sits inside Python, inside your logging configuration, and inside every pipeline where a producer's chatty output meets a slow consumer. This article walks through what line buffering actually does at the syscall boundary, why terminals force you onto the slow path, and how a deadline-based flush works, with a small Python implementation you can adapt.
What the kernel actually sees
Programmers tend to think of puts or print as "write this to the screen." The kernel sees something much more granular. Wellons demonstrates the gap with a three-line C program and strace:
#include <stdio.h> int main(void) { puts("a\nb"); puts("c"); } Run it under a syscall trace on a glibc system and the writes come out split at every newline:
write(1, "a\n", 2) = 2 write(1, "b\n", 2) = 2 write(1, "c\n", 2) = 2 The string "a\nb" was a single argument to a single puts call, and glibc still split it into two write syscalls because line buffering is interpreted literally: a newline completes a line, and a completed line must be flushed immediately. Two lines in, two syscalls out. Now scale that behavior up to a program emitting fifty thousand log lines, a build system printing every file it compiles, or an extractor listing every entry in an archive. Each line is a round trip through the syscall boundary, and on the far side of that boundary sits a terminal emulator that will happily consume far less than one syscall per line.
The crucial detail is that nothing about the destination requires this granularity. A terminal renders characters, not lines; it does not know or care whether a\nb\n arrived in one write or two. The per-line splitting is a policy decision made inside the runtime, and policies can be changed.
Why a terminal flips you into the slow path
Standard C gives you three buffering modes, selected with setvbuf: fully buffered (_IOFBF), line buffered (_IOLBF), and unbuffered (_IONBF). The part most people never notice is the default selection logic: when a stream is attached to an interactive device, the implementation picks line buffering; otherwise it picks full buffering. The probe is typically isatty, and the intent is reasonable. A human staring at a terminal should see output promptly, while a file on disk should be written in big efficient chunks.
The side effect is a behavior cliff. The same program, with the same code, runs in two different output regimes depending on where its standard output points, and the regimes differ by orders of magnitude in syscall count. Redirect a chatty program into a file or a pipe and it suddenly gets faster, because the lines now accumulate in a userspace buffer until it fills, often four or eight kilobytes at a time. This is why Wellons notes that avoiding line buffering is a known trick: people working around it redirect output or pass a quiet flag, trading away the visibility they actually wanted.
Python inherits the same shape of decision, with its own twist. CPython's rule for a line-buffered text stream is to flush after appending new output if the buffer now contains a newline. That can emit partial lines, but more importantly it preserves the essential property: newlines are flush triggers, and flush triggers are syscalls. Wrapping the same behavior in a friendlier object does not remove the cost.
The unzip symptom
Wellons' motivating example is worth repeating because it is so ordinary. Info-ZIP's unzip prints the name of every file as it extracts it. For archives full of small files, that printout, not the decompression, dominates the wall-clock time. His workaround as a user was to cancel the extraction and restart it with the quiet flag, because restarting the entire job was faster than letting the terminal keep up with the listing.
Stop and appreciate how strange that is. The program's actual work, inflating data, is computationally trivial next to the cost of telling you about it one line at a time. The user interface is technically informative and practically hostile: the more faithfully it reports, the slower the job runs, and the only escape hatch is to ask for silence. Anyone who has watched a build log scroll by has lived a milder version of the same story, and anyone who runs CI has paid for it at scale, since the log of a chatty build is both the artifact you want and the bottleneck you do not.
When he wrote his own extractor, tugz, he kept the listing and removed the bottleneck. The mechanism is the interesting part, and it generalizes.
A deadline is a better flush trigger than a newline
The reframing in one sentence: the real requirement is not "flush every line," it is "do not hold output hostage indefinitely." Those are different contracts. A newline is a proxy for a moment when the user might be waiting, and it is a wasteful proxy, because a program printing ten thousand lines in a burst does not need ten thousand flushes to keep a human informed. The human cannot read the burst anyway; they only need the display to stay live.
The alternative trigger is time. Give the output buffer a deadline on the order of a hundred milliseconds, well inside human perception. When the buffer holds data and the deadline passes, flush. When new data arrives before the deadline, extend or reset it. Wellons points out the analogy to Nagle's algorithm in TCP: both exist to protect a stream from an oblivious caller making many tiny writes, by holding data briefly and coalescing it. The core of his design, adapted from his article, fits in a few lines:
void deadline_flush(Buf *b) { if ((b->flags & BUF_LINE) && b->len) { int64_t now = os_monotonic_clock(); if (b->deadline && now > b->deadline) { flush(b); b->deadline = 0; } else { b->deadline = now + DEADLINE; } } } Two properties make this practical rather than merely cute. First, it needs no background thread and no timer interrupts: the program pokes deadline_flush at natural points in its own work loop, such as once per decompressed block, so the flush check rides along on work the program was already doing. Second, the observable behavior for a human is indistinguishable from line buffering. If the extractor processes three files within one deadline window, their three names go out in a single write; the listing still appears continuously, the program just stops paying one syscall per name. In tugz the result is a listing you get for free instead of a listing you pay for.
A small Python implementation
The same pattern drops into Python in a few dozen lines. The wrapper below buffers bytes, flushes when a deadline expires, and expects the owner to call poke at convenient points in its work loop, mirroring the tugz structure. When the buffer grows large, it flushes early, so throughput for huge writes is unaffected:
import os import time class DeadlineWriter: def __init__(self, fd, deadline=0.1, chunk=65536): self.fd = fd self.deadline = deadline self.chunk = chunk self.buf = bytearray() self.flush_at = None def write(self, data): self.buf += data if self.flush_at is None: self.flush_at = time.monotonic() + self.deadline if len(self.buf) >= self.chunk: self.flush() def poke(self): if self.buf and self.flush_at is not None: if time.monotonic() >= self.flush_at: self.flush() def flush(self): if self.buf: os.write(self.fd, bytes(self.buf)) self.buf.clear() self.flush_at = None def close(self): self.flush() Used inside a work loop, the call sites look exactly like the C version:
out = DeadlineWriter(1) # stdout for name in archive.namelist(): out.write(f" inflating: {name}\n".encode()) data = archive.read(name) for block in iter_blocks(data): process(block) out.poke() out.close() Every line still reaches the terminal, but lines produced within the same hundred-millisecond window share a syscall. For a burst of ten thousand lines, you have traded ten thousand writes for a handful, and the display updates at ten frames per second, far above anything a reader can track. If the process crashes, the worst case is losing a hundred milliseconds of buffered status output, which for a listing is an acceptable trade and is exactly the kind of trade you should make consciously rather than by accident.
Where deadline flushing does not fit
The pattern has real boundaries, and pretending otherwise would undersell it. Interactive prompts are the clear exception: if your program prints Password: and waits for input, that text must reach the screen before the read, so prompt output still wants an explicit flush. Progress indicators that redraw in place with carriage returns are a second exception; their cadence is the point, and coalescing redraws changes what the user sees. Error streams deserve their own policy too. When something has gone wrong, promptness usually beats efficiency, which is why stderr is unbuffered by convention in the first place.
There is also a correctness footnote for the Python version above: os.write is not guaranteed to write the whole buffer in one call on every platform, so production code should loop over short writes or use os.writev where available. And if several threads share one writer, the buffer needs a lock, at which point you are rebuilding a small piece of what a logging framework already gives you, so it can be more honest to put the deadline inside a custom logging handler instead.
Batching with a latency bound is a general pattern
Step back from terminals and the shape repeats everywhere. Metrics pipelines buffer samples and flush on an interval. Write-ahead logs group commit to amortize fsync. Message brokers accumulate records into batches for exactly the same reason Nagle accumulates segments: the per-item overhead dominates unless items ride together, and the only question is how much latency you will pay to buy the batching. Line buffering is simply the place where this question was answered for you, decades ago, with a proxy that made sense when output was slow and programs printed occasionally.
The useful habit is to notice the proxy. Whenever a system flushes, commits, renders, or sends "eagerly," ask what the eagerness is keyed to. If it is keyed to a convenient syntactic marker like a newline rather than to an actual latency requirement, there is probably throughput hiding in the gap between the two. Wellons' article is titled as a complaint about line buffering, but its real content is that the fix took less code than the workaround habits people had built around the problem.
The next time a tool you are writing starts to feel slow the moment it gets chatty, resist the urge to add a quiet flag as the only escape. Add a deadline to the buffer instead. Your users keep their listing, your syscall count drops by the size of the burst, and the only thing you gave up is a policy nobody chose on purpose.
Source and credit: the mechanism, the glibc and CPython write behavior, the unzip example, and the tugz design all come from Chris Wellons, "We can do better than line-buffered output" (nullprogram.com, November 2026).
Originally published on Dispatch.

