Exit Codes and Error Output for Rust Command Line Tools
Exit codes are how scripts know your tool failed. Return 0 for success, a nonzero code for failure, send errors to stderr, and let main return ExitCode or Result.

A shell script that runs your tool only has one question: did it work. The exit status is the answer, and getting it wrong means && chains continue after a failure or set -e scripts stop on success. The Rust side takes four lines. This post covers main signatures, a small code scheme and the stderr rule. It is part of the command line tool series.
Rust 1.90, anyhow 1.
What main can return
use std::process::ExitCode;
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
ExitCode::from(1)
}
}
}
fn run() -> anyhow::Result<()> {
anyhow::bail!("config file not found");
}Running it prints error: config file not found on stderr and the shell sees $? equal to 1. Returning ExitCode gives you control of the message format, which the next form does not.
main returning Result
fn main() -> Result<(), Box<dyn std::error::Error>> {
std::fs::read_to_string("missing.txt")?;
Ok(())
}On failure it prints Error: Os { code: 2, kind: NotFound, message: "No such file or directory" } using Debug formatting, and exits with 1. That is acceptable for scripts and ugly for users. The format with anyhow is nicer, as shown in anyhow versus thiserror.
A small scheme
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | The operation failed |
| 2 | Usage error (clap does this) |
| 3 | Not found, if scripts need to tell |
| 130 | Killed by Ctrl+C, by Unix convention (128 + 2) |
Do not return more than a handful. ExitCode::from(u8) only takes 0 to 255, and the shell truncates larger values.
stdout, stderr
Results go to stdout so tool | next works. Messages, warnings and errors go to stderr so they appear on the terminal even when stdout is redirected. A tool that prints "error: ..." to stdout will write it into the user's output file.
println!("{}", result);
eprintln!("warning: skipped {path}");Why not process::exit
std::process::exit(1) ends the process immediately. Values in scope are not dropped, so a BufWriter holding unflushed data loses it. Return up to main and let it end normally. The only common exception is a signal handler.
Closed pipes
When stdout is a closed pipe, such as tool | head -1, writes fail with BrokenPipe. Rust ignores SIGPIPE, so println! panics. Handle it as shown in broken pipe when writing to standard output.
Where conventions end
The sysexits.h set of codes (64 and up) exists but is rarely followed outside mail software. Windows treats codes as 32 bit values. In scripts, check $? immediately, because the next command overwrites it. The standard library documents ExitCode, and signals and Ctrl C handling covers interrupted exits.


