TTY and Buffering

7 min read Original article ↗

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: