20.3stdin, stdout, and stderr
Every command-line program is born connected to three streams, whether it uses them or not. Standard input (stdin) is where input arrives, standard output (stdout) is where normal results go, and standard error (stderr) is where problems are reported. You've already used the first two without naming them: read_line reads from stdin, println! writes to stdout. This lesson names the third, explains why keeping output and errors on separate streams matters, and shows how these streams let small tools combine into big ones.
The streams you already use
println! writes to stdout. read_line reads a line from stdin. That much is review from chapter 1. What's new is the why: these are separate, redirectable channels, not just "the screen" and "the keyboard." When you run a program in a terminal, all three are wired to the terminal by default, which is why output appears and typing works. But each can be redirected independently, and that's the whole point of having three.
Reading all of stdin (useful when your tool is meant to process piped-in text rather than a named file) looks like this:
use std::io::{self, Read};
fn main() {
let mut input = String::new();
io::stdin().read_to_string(&mut input).expect("failed to read stdin");
let line_count = input.lines().count();
println!("{line_count} lines");
}
io::stdin() is a handle to the standard input stream; read_to_string drains all of it into a String (the same method name as the file version last lesson, because both are just "a source of bytes"). Then input.lines().count() is plain chapter-19 iteration. Run with input piped in:
$ printf 'a\nb\nc\n' | cargo run3 lines
The | there is a pipe: it connects the stdout of the program on the left (printf) to the stdin of the program on the right (your tool). That connection is the foundation of the Unix command-line philosophy, small programs each doing one thing, strung together with pipes. Your tool just read its input; it neither knew nor cared that the input came from another program rather than the keyboard.
Why errors go on a separate stream
Here's the design decision that confuses newcomers and then, once it clicks, never does. Normal output goes to stdout. Error messages and diagnostics go to stderr. Two streams for what might seem like one job. The reason is composability.
Imagine a tool that searches a file and prints matching lines. Someone pipes its output into another program, or redirects it into a file: mytool query data.txt > results.txt. They want results.txt to contain only the matches, the actual results, not "Searching data.txt..." or "Warning: 3 lines skipped." If those status messages went to stdout, they'd land in results.txt, corrupting the data. By putting results on stdout and everything else on stderr, the two never mix: the > redirect captures stdout (clean results) while stderr still prints to the terminal where the human can see it.
In Rust, stdout is println! and stderr is eprintln! (and eprint!), introduced for debugging back in lesson 3.4. Now you see its real job:
fn main() {
let input = "12\nnot a number\n7";
for line in input.lines() {
match line.parse::<i32>() {
Ok(n) => println!("{}", n * 2), // result -> stdout
Err(_) => eprintln!("skipping invalid: {line}"), // diagnostic -> stderr
}
}
}
Run normally, both streams show in the terminal interleaved:
24
skipping invalid: not a number
14
But redirect stdout to a file (cargo run > out.txt), and only the results land there:
out.txt:
24
14
while skipping invalid: not a number still prints to the terminal, because it went to stderr, which wasn't redirected. The diagnostic reached the human without polluting the data. That separation is why eprintln! exists, and why every well-behaved tool sends results to stdout and everything else to stderr.
Best practice
Send your program's actual output, the data a user or another program wants, to stdout with println!/print!. Send everything else, progress messages, warnings, errors, usage text, to stderr with eprintln!/eprint!. This keeps your tool pipeable: redirecting or piping stdout captures clean results, while diagnostics still reach the terminal. Mixing the two is the most common way a tool becomes painful to compose with others.
Exit codes
The other half of a tool's conversation with the outside world is its exit code: a small integer the program returns when it ends, by convention 0 for success and any non-zero value for failure. The shell and other programs read it to decide what to do next (this is what && in build && test checks, run the second command only if the first exited zero). You met echo $? and exit codes back in lesson 7.8; here they're part of being a good command-line citizen.
The cleanest way to set the exit code is to have main return a Result. If main returns Ok, the process exits 0; if it returns Err, Rust prints the error to stderr (correctly) and exits with a non-zero code:
use std::fs;
fn main() -> Result<(), std::io::Error> {
let contents = fs::read_to_string("config.txt")?;
println!("{} bytes of config", contents.len());
Ok(())
}
If config.txt exists, it prints the size and exits 0. If it doesn't, the ? propagates the error, Rust prints something like Error: Os { code: 2, kind: NotFound, message: "No such file or directory" } to stderr, and the process exits non-zero, all automatically. This is the lesson-12.3 main-returns-Result pattern doing real work: clean exit codes and error-to-stderr for free, no manual process::exit needed.
Key insight
A command-line program's interface to the rest of the system is four channels: arguments and stdin coming in, stdout and stderr going out, and an exit code on the way out the door. A tool that respects all four, reads its input, writes results to stdout, writes diagnostics to stderr, and exits zero on success, drops cleanly into pipelines and scripts alongside every other Unix tool. The project at the end of this chapter is built to honor exactly these conventions.
Quiz time
Question #1
What's the difference between stdout and stderr, and why have both?
Show solution
stdout carries the program's actual output (the results); stderr carries diagnostics, warnings, errors, and progress messages. They're separate so a user can redirect or pipe stdout (capturing clean results into a file or another program) while diagnostics still print to the terminal. If both shared one stream, status messages would contaminate the data whenever output was redirected. In Rust, stdout is println! and stderr is eprintln!.
Question #2
A tool prints results with println! and warnings with println! too. A user runs tool input > out.txt and complains that out.txt has warning text mixed into the data. What's the fix?
Show solution
Print the warnings with eprintln! instead of println!, so they go to stderr. The > redirect only captures stdout, so with warnings on stderr, out.txt would contain only the results while the warnings still appear in the terminal. The bug is that diagnostics were being written to stdout, where the redirect swept them into the file.
Question #3
How does returning a Result from main set the program's exit code, and where does the error text go?
Show solution
If main returns Ok(()), the process exits with code 0 (success). If it returns Err(e), Rust prints the error (via its Debug form) to stderr and exits with a non-zero code, automatically. This gives you correct exit codes and error-on-stderr behavior for free, just propagate errors with ? up to main, with no manual process::exit call (the lesson-12.3 pattern).
Arguments, files, and the standard streams are configured at the moment a program runs. The last input source, the next lesson (20.4), comes from the surrounding environment: environment variables, the standard way to configure a tool's behavior without cluttering its arguments.