Before you begin
Why this matters
Most of the time output appears when you print it, so the times it does not are confusing: a prompt from print! that never shows, two messages in the wrong order, or a last line missing after process::exit. Once you know where text waits in Rust, you can predict these and fix them.
What this helps you reason about
- Predict whether a message is on the screen before a slow step runs.
- Explain why two messages can appear in a different order from the code that wrote them.
- Spot text that will be lost if the program stops early, and send it out in time.
When you call println!, you see the line at once, and for whole lines that model is correct. This lesson asks what sits between that call and the screen: a line buffer inside standard output, a BufWriter you may add, and what happens to waiting text when a program exits early.
Already know this? The short version
- Rust’s stdout is a LineWriter, a writer that sends its text at each newline: each whole line from println! is sent at once, for files and pipes too; print! without a newline waits for io::stdout().flush().
- stderr is unbuffered; a BufWriter (8 KB by default) flushes on flush(), when full, or when dropped, and a flush during drop ignores errors.
- std::process::exit runs no destructors, so text in a BufWriter is lost, while stdout’s own buffer is flushed at exit; a panic that unwinds drops and flushes the BufWriter, and abort or a crash does not.
- Flush the buffered side before eprintln! when order matters; flush hands bytes to the operating system, and File::sync_all asks for a disk write.
Words in this lesson (8)
- operating system
- The software, such as Linux, macOS, or Windows, that runs programs and controls the screen, files, and other devices. Every write a program makes is a request to it.
- stream
- A destination that receives bytes in order. A program writes to streams; it does not draw on the screen itself.
- standard output and standard error
- The two output streams every program starts with. Standard output is for normal results; standard error is for messages about problems.
- terminal and pipe
- A terminal is the window where you type commands. A pipe is a connection that carries one program’s output into another program’s input.
- buffer
- An area of memory where written text waits inside the program before it is sent on to the operating system.
- line buffering and full buffering
- A line-buffered stream sends its text at each newline. A fully buffered stream sends its text only when the buffer is full or is flushed.
- flush
- To send the text waiting in a buffer on to the operating system now. A flush is not a disk write.
- drop and destructor
- A value is dropped when the variable that owns it goes out of scope. Dropping runs the type’s destructor, its cleanup code; a BufWriter’s destructor flushes it.
Examples are focused snippets; imports, enclosing functions, or classes may be omitted. C examples use C11; Haskell examples use Haskell 2010. Expand the notes for language-specific depth.
01 / A write call
What does a print call promise?
Here is a useful model: println! writes to the screen immediately. For most small programs, it predicts exactly what you see.
Underneath, a program does not draw on the screen itself. It asks the operating system to write bytes to a stream, and the operating system delivers them.
Every program starts with two output streams. Standard output is for the program’s normal results; println! writes there. Standard error is for messages about problems; eprintln! writes there.
The program does not choose where these streams go. Whoever starts the program chooses. A stream may go to a terminal, to a file, or through a pipe into another program.
println!("loading");
let mut total = 0;
for i in 1..=3 {
total += i;
}
println!("ready {}", total); A write passes text to a stream. The program does not choose where that stream goes.
Precisely: stdout is one shared handle
std::io::stdout() returns a handle to one global standard output, protected by a lock. println! locks it, writes, and unlocks on every call. Code that prints many lines can call io::stdout().lock() once and write through the lock.
Experiment
Watch when each write reaches the output panel
Rust’s standard output keeps a line buffer: it holds text until a newline arrives, then passes the whole line on. println! ends with a newline, so each line is sent at once. print! without a newline waits for the next one.
Why it matters Whole lines from println! appear when they are printed. A partial line from print! needs io::stdout().flush() if it must appear now.
Predict at which stop loading first appears. Then change println!("loading") to print!("loading... ") and predict again.
fn main() {
println!("loading");
let mut total = 0;
for i in 1..=3 {
total += i;
}
println!("ready {}", total);
}
What to look for
Step past the first println! and look at the output panel: loading is there before the loop starts. After the edit, loading... stays hidden through the whole loop and appears together with ready.
Expected output
loading
ready 602 / Buffers
Where does written text wait?
Asking the operating system to write takes time, much more than copying bytes in memory. So programs often collect text in a buffer first: an area of memory where written text waits until there is enough to send at once.
Rust’s standard output has a line buffer: it holds text until a newline is written and then passes the whole line on. So println! appears at once, while print! without a newline waits. Many programs also wrap standard output in a BufWriter, which holds up to 8 KB and passes it on only when flushed, full, or dropped.
So the model from section 01, that a print writes to the screen immediately, needs one addition: written text can still be inside the program. Anything that does not go through that buffer can arrive first.
let mut out = BufWriter::new(io::stdout());
writeln!(out, "1 queued").unwrap(); // waits in out
println!("2 direct"); // a whole line: sent at once
out.flush().unwrap(); A buffer holds written text inside the program. Until it is sent on, nothing outside the program can see it.
Coming from Python: Python’s print is block-buffered when output goes to a pipe; Rust’s println! still sends each whole line at its newline.
Precisely: stdout’s buffer in the stepper
The stepper uses Rust 1.63, the version that Debian 12 packages; every observation in this lesson comes from it. The Rust documentation describes stdout as line-buffered when connected to a terminal. In the stepper, where output goes to a file, stdout was line-buffered too, and each println! line appeared at the next stop. The standard library uses a LineWriter for stdout whatever it is connected to.
Experiment
Make a direct write arrive before a buffered one
BufWriter keeps its own buffer between your code and stdout and passes text on only when flushed, when full, or when it is dropped. println! goes through stdout’s line buffer, which sends each whole line at once, so it does not wait for out.
Why it matters When text reaches one output by two paths and only one path is buffered, lines can come out in a different order from the code. Flush the buffered path before writing on the other one.
Predict the order of the three lines. Then move out.flush().unwrap() above println!("2 direct") and predict again.
use std::io::{self, BufWriter, Write};
fn main() {
let mut out = BufWriter::new(io::stdout());
writeln!(out, "1 queued").unwrap();
println!("2 direct");
out.flush().unwrap();
writeln!(out, "3 queued").unwrap();
}
What to look for
Watch the output panel at each stop. 2 direct appears first. 1 queued appears at the stop after out.flush(). 3 queued has no flush of its own: it appears only when the run ends, as out is dropped at the end of main. In the variables panel, out’s buf shows len=9 while 1 queued waits, and len=0 after the flush. After the edit, 1 queued appears first.
Expected output
2 direct
1 queued
3 queued03 / Flushing
What moves text out of a buffer?
To flush a buffer is to send the text waiting in it on to the operating system now. Buffers are flushed in four common ways. The program asks for it: an explicit flush. A newline is written: line buffering. The buffer is full and needs room. And some buffers, in some languages, are flushed when the program exits normally. Which of them apply depends on the language and on the buffer.
stdout’s line buffer flushes at each newline and when you call io::stdout().flush(). A BufWriter flushes when you call flush, when it is full, and when it is dropped: when the variable that owns it goes out of scope. flush is a method of the Write trait, a set of methods that writers share, so use std::io::Write must be in the file to call it.
use std::io::{self, Write};
print!("Name: ");
io::stdout().flush().unwrap(); // a prompt has no newline Flushing moves text from a buffer to the operating system. print! without a newline needs an explicit flush.
Coming from C: C’s exit flushes every FILE stream; Rust’s std::process::exit flushes stdout but runs no destructors, so a BufWriter is not flushed.
Precisely: errors when dropping
BufWriter’s documentation says to call flush before it is dropped: dropping also flushes, but any error from that write is ignored. A flush hands bytes to the operating system; File::sync_all asks it to write a file to the disk.
04 / Terminals and pipes
Why can the same program print differently in a terminal?
Many buffers change their behavior depending on where output goes. A person at a terminal wants to see each line as soon as it is printed. A program reading from a pipe, or a file being written, does better with fewer, larger writes. So a common choice is line buffering for a terminal and full buffering for a pipe or a file. A fully buffered stream is flushed only when its buffer fills up or when the program asks.
Rust’s stdout keeps its line buffer wherever output goes. Whole println! lines appear when printed in a terminal and through a pipe alike. A BufWriter is the opposite: it fills up first in every case. Line buffering makes one write per line, so programs that print thousands of lines to a pipe add a BufWriter for speed.
let stdout = io::stdout();
let mut out = BufWriter::new(stdout.lock()); // one write per 8 KB
for i in 0..10_000 {
writeln!(out, "{}", i).unwrap();
}
out.flush().unwrap(); Know whether your output is line-buffered or fully buffered; in Rust that depends on what you wrapped it in.
Precisely: detecting a terminal
Rust 1.70 added std::io::IsTerminal, so io::stdout().is_terminal() tells you whether output goes to a terminal; the stepper’s older Rust does not have it. The stepper runs Rust under the GDB debugger with standard output sent to a file, never a terminal.
05 / Two streams
Why do errors and results come out of order?
Standard output and standard error are separate streams, so they can go to different places. Whoever starts a program can send its standard output to a file while standard error still appears in the terminal. That is why results go to standard output and problems to standard error: a program that reads the results never sees the warnings.
The two streams can also go to the same file. Then the order of lines in that file depends on buffering. If standard output is buffered and standard error is not, a warning can appear in the file before results that the program wrote earlier.
In Rust, stdout is line-buffered and stderr is not buffered, so whole lines from println! and eprintln! keep their order even in one file. A partial line from print! can appear after a later eprintln! line, and so can anything waiting in a BufWriter. Flush the buffered side before writing to the other stream.
let mut out = BufWriter::new(io::stdout());
for value in values {
match convert(value) {
Some(result) => writeln!(out, "{}", result).unwrap(),
None => {
out.flush().unwrap(); // earlier results first
eprintln!("warning: skipped {}", value);
}
}
}
out.flush().unwrap(); Results go to standard output, problems to standard error. Flush buffered results before writing to the other stream.
Precisely: redirecting in a terminal, and the output panel
In a terminal, tool > results.txt runs a program named tool, and > sends its standard output to the file results.txt. tool > run.log 2>&1 sends both streams to run.log. Read 2>&1 one piece at a time: 2 is the number of standard error, > sends it somewhere, and &1 means stream 1, standard output, not a file named 1. So 2>&1 means “send standard error where standard output goes”. The stepper sends standard error to a separate file and shows only standard output, so the buffers experiment uses println! as the unbuffered path. eprintln! writes to standard error, which has no buffer at all.
06 / Lost output
What happens to waiting text when a program stops early?
Text in a buffer is still inside the program. If the program stops before the buffer is flushed, that text is gone. This happens when a program crashes, when the operating system stops it, and when it ends through a call that skips the normal cleanup.
In Rust, dropping a value runs its destructor: the cleanup code for that type. A BufWriter’s destructor flushes it. std::process::exit ends the process without running destructors, so the BufWriter is never flushed. A panic in main normally unwinds and drops values, which does flush it; std::process::abort and a crash such as a stack overflow do not.
let mut out = BufWriter::new(io::stdout());
writeln!(out, "saved").unwrap();
out.flush().unwrap();
writeln!(out, "lost").unwrap();
process::exit(0); // out is never dropped Buffered text survives only if something flushes it. Flush before process::exit, or let main return.
Coming from JavaScript: Like process.exit() in Node.js, std::process::exit ends the program at once; unlike JavaScript, a BufWriter flushes itself when main returns normally.
Precisely: stdout’s own buffer at exit
Observed in the stepper: text from print! still waiting in stdout’s line buffer was written when process::exit ran, because the standard library flushes stdout during exit. It cannot do the same for a BufWriter you created, since that needs its destructor. With abort, the print! text was lost too.
Experiment
Exit while text is still in a buffer
process::exit ends the process without running destructors: the cleanup code that runs when a value is dropped. BufWriter flushes in its destructor, so that flush never happens. Without process::exit, out is dropped at the end of main and lost appears.
Why it matters Flush a BufWriter yourself before process::exit, or let main return so that values are dropped.
Predict whether lost is printed. Then delete process::exit(0) and predict again.
use std::io::{self, BufWriter, Write};
use std::process;
fn main() {
let mut out = BufWriter::new(io::stdout());
writeln!(out, "saved").unwrap();
out.flush().unwrap();
writeln!(out, "lost").unwrap();
process::exit(0);
}
What to look for
Watch the output panel. saved appears at the stop after the flush. lost never appears, not even after the run has ended.
Expected output
saved07 / Try it
Can you predict what reaches the output?
Predict the output before revealing the answer. Follow out’s buffer and stdout’s line buffer separately.
use std::io::{self, BufWriter, Write};
use std::process;
fn main() {
let mut out = BufWriter::new(io::stdout());
writeln!(out, "one").unwrap();
println!("two");
out.flush().unwrap();
writeln!(out, "three").unwrap();
println!("four");
process::exit(0);
}
What does this print? Does every line appear?
Reveal the reasoning
two, one, four. one waits in out’s buffer, while println!("two") is a whole line and is sent at once. out.flush() then sends one. three waits in out again, four leaves at once, and process::exit ends the program without dropping out, so three is lost.
Practice tickets
- IO-101 · Show progress while photos copy → Free
- IO-102 · Keep the move journal after a crash → Sign in · Pro
- IO-103 · Separate results from warnings → Sign in · Pro