Every developer has at least once in their career stumbled upon the scenario where a program would not print something as they initially thought. A few years ago, you would search for an answer on StackOverflow and find a page stating the dreaded
you have to flush the buffer
I’d never been curious enough to dig into the 'why' until recently. Let’s take a look at a quick example:
#include <stdio.h>
#include <unistd.h>
int main() {
for (int i = 1; i <= 5; i++) {
printf("line %d\n", i);
sleep(1);
}
return 0;
}
If we compile and run this in our terminal, we’ll get the following:
$ gcc -o cprint cprint.c
$ ./cprint
line 1 ← appears at t=1
line 2 ← appears at t=2
line 3 ← appears at t=3
line 4 ← appears at t=4
line 5 ← appears at t=5
Something different happens if you pipe the result:
$ ./cprint | cat
← nothing for 5 seconds...
line 1 ← appears at t=5
line 2
line 3
line 4
line 5
The answer lies in how buffering is handled in TTY and in non-TTY environments.
A libc implementation will use line buffering if it detects that we’re in a
TTY; otherwise, it will use full buffering if we’re in a non-TTY. Line
buffering flushes data as soon as a \n character is encountered. On the other
hand, full buffering typically accumulates data until the buffer is full, often
around 4KB to 8KB.
It is worth noting that stderr is an exception to this rule. While stdout
changes behavior based on the environment, stderr is typically unbuffered or
line buffered even when piped. This ensures that if a program crashes, the
error message reaches the screen immediately rather than being stuck in a full
buffer.
Our previous example doesn’t demonstrate the effect of line buffering clearly because we used newlines. Let’s look at a Rust example that highlights this:
use std::io::{self, Write};
use std::thread::sleep;
use std::time::Duration;
fn main() {
print!("Hello");
sleep(Duration::from_secs(3));
println!(" World!");
}
$ cargo run
← nothing for 3 seconds...
Hello World!
Rust’s print! implementation also performs line buffering, which causes the
first print to only show up along with the second one after a \n character is
encountered. You won’t notice any difference if you run cargo run | cat in
this case as both behave the same: output is printed in a single shot after 3
seconds.
However, we can force a flush to override this behavior:
use std::io::{self, Write};
use std::thread::sleep;
use std::time::Duration;
fn main() {
print!("Hello");
io::stdout().flush().unwrap();
sleep(Duration::from_secs(3));
println!(" World!");
}
$ cargo run
Hello ← t=0
Hello World! ← t=3
We’re forcing a flush this time, and the first print appears immediately upon
program execution. Then the final print writes its content after 3 seconds.
What happens if we pipe this flushed output?
$ cargo run | cat
Hello ← t=0
Hello World! ← t=3
If you thought that | cat would change the result to print everything after
3s, you’re wrong. A manual flush will override the full buffering behavior and
push the buffered data through regardless of the environment.
Now we know that different libraries can behave differently when TTY/non-TTY is detected, but what is a TTY?
In short, TTYs are interactive sessions opened by terminals. By contrast, non-TTYs include data streams like pipes and redirects..
How do we detect a TTY programmatically? Different languages provide their own
methods. Rust has a simple is_terminal() function as part of the
std::io::IsTerminal trait.
use std::io::{self, IsTerminal, Write};
fn main() {
let is_tty = io::stdout().is_terminal();
if is_tty {
write!(&mut io::stdout(), "TTY!").unwrap();
} else {
write!(&mut io::stdout(), "NOT TTY!").unwrap();
}
}
$ cargo run
TTY!
$ cargo run | cat
NOT TTY!
$ cargo run > output && cat output
NOT TTY!
This output confirms the underlying logic: when we pipe to cat or redirect to
a file, the program detects a non-TTY environment.
By now, you may have guessed why this distinction matters. Far from being a niche technical detail, TTY detection is the base for a wide range of optimizations and DX decisions that depend on knowing exactly who or what is on the other end of the line.
Let’s take a real example from the ripgrep CLI tool.
let mut printer = StandardBuilder::new()
.color_specs(ColorSpecs::default_with_color())
.build(cli::stdout(if std::io::stdout().is_terminal() {
ColorChoice::Auto
} else {
ColorChoice::Never
}));
This is a classic use case: colored text is essential for human readability in
a terminal, but it becomes a nuisance in a non-TTY environment. If you’ve ever
tried to grep through a file only to find it littered with messy ANSI escape
codes like ^[[31m, you know exactly why ripgrep makes this choice.
/// Returns a possibly buffered writer to stdout for the given color choice.
///
/// The writer returned is either line buffered or block buffered. The decision
/// between these two is made automatically based on whether a tty is attached
/// to stdout or not. If a tty is attached, then line buffering is used.
/// Otherwise, block buffering is used. In general, block buffering is more
/// efficient, but may increase the time it takes for the end user to see the
/// first bits of output.
///
/// If you need more fine grained control over the buffering mode, then use one
/// of `stdout_buffered_line` or `stdout_buffered_block`.
///
/// The color choice given is passed along to the underlying writer. To
/// completely disable colors in all cases, use `ColorChoice::Never`.
pub fn stdout(color_choice: termcolor::ColorChoice) -> StandardStream {
if std::io::stdout().is_terminal() {
stdout_buffered_line(color_choice)
} else {
stdout_buffered_block(color_choice)
}
}
This implementation brings us full circle. By checking is_terminal(), ripgrep
chooses the best of both worlds: line buffering to output line by line when a
human is watching the terminal, and block buffering for max performance when
the output is being sent to another file or process.
You may have noticed that I’ve used Rust for every example except the first one. There is a very specific and interesting reason for that.
Our first example’s aim is to show full buffering when the program is run in non-TTYs. Let’s revisit that C example:
#include <stdio.h>
#include <unistd.h>
int main() {
for (int i = 1; i <= 5; i++) {
printf("line %d\n", i);
sleep(1);
}
return 0;
}
Now here’s the equivalent Rust code:
use std::thread::sleep;
use std::time::Duration;
fn main() {
for i in 1..=5 {
println!("line {}", i);
sleep(Duration::from_secs(1));
}
}
$ cargo run
line 1 ← appears at t=1
line 2 ← appears at t=2
line 3 ← appears at t=3
line 4 ← appears at t=4
line 5 ← appears at t=5
$ cargo run | cat
line 1 ← appears at t=1
line 2 ← appears at t=2
line 3 ← appears at t=3
line 4 ← appears at t=4
line 5 ← appears at t=5
Unlike the C version, Rust produces identical output in both TTY and non-TTY environments and line buffering is used in both cases. How’s that even possible?
Well, I previously wrote that this behavior is an implementation detail and not
something that happens in every language or library. Surprisingly, Rust, as of
now, uses line buffering for both TTYs and non-TTYs. We can see this by looking
at the Stdout implementation:
#[stable(feature = "rust1", since = "1.0.0")]
pub struct Stdout {
// FIXME: this should be LineWriter or BufWriter depending on the state of
// stdout (tty or not). Note that if this is not line buffered it
// should also flush-on-panic or some form of flush-on-abort.
inner: &'static ReentrantLock<RefCell<LineWriter<StdoutRaw>>>,
}
As you can see, LineWriter is the default struct used by Stdout. The
FIXME comment shows the Rust team acknowledges that ideally they should check
if something is executed in TTYs or not and use LineWriter or BufWriter
accordingly, but I guess this was not on their priority list.
Next time your output isn’t appearing when you expect it to, you’ll know
exactly where to look. And it will be interesting to see if the Rust team
eventually addresses that FIXME and implements the best buffering strategy
for different environments. Until then, at least Rust’s consistent behavior
means one less thing to debug.
If you want to read more about TTYs, here are some really cool resources: