How to exit with a status code
Short answer: Call std::process::exit(code) to leave with the status you choose — but it ends the process on the spot, skipping every destructor. Returning std::process::ExitCode from main sets the same code and keeps the cleanup.
From a fresh project — cargo new exit-code — replace src/main.rs with each block below and run it with cargo run -q. cargo run passes the program’s exit status through unchanged, so echo $? straight afterwards prints what the program returned.
The call
Section titled “The call”use std::process;
fn main() { let mut args = std::env::args().skip(1); match args.next() { Some(name) => println!("hello, {name}"), None => { eprintln!("usage: exit-code <name>"); process::exit(2); } }}$ cargo run -qusage: exit-code <name>$ echo $?2
$ cargo run -q -- adahello, ada$ echo $?0process::exit takes an i32 and never returns — its return type is !, which is why the None arm needs no return and no else. Two is the conventional code for a usage error. The message goes to stderr with eprintln!, so it stays visible when stdout is piped somewhere else.
What process::exit skips
Section titled “What process::exit skips”struct Guard;
impl Drop for Guard { fn drop(&mut self) { println!("guard dropped"); }}
fn main() { let _guard = Guard; println!("before exit"); std::process::exit(0);}$ cargo run -qbefore exit$ echo $?0guard dropped never prints. process::exit ends the process where it stands: no unwinding, no destructors, nothing in Drop runs for values that are still alive. Delete the exit line so main returns normally, and the same program prints both lines:
$ cargo run -qbefore returnguard dropped$ echo $?0That difference is the reason to think before reaching for exit. Cleanup that lives in Drop is lost: remove a temporary file and it stays on disk. A BufWriter flushes in its destructor, so a file it was writing is left empty — write one line through a BufWriter<File>, call process::exit(0), and the file on disk holds zero bytes; return from main instead and it holds the line.
The return route
Section titled “The return route”main can return ExitCode, and that path runs destructors like any other:
use std::process::ExitCode;
fn main() -> ExitCode { match std::env::args().nth(1) { Some(code) => match code.parse::<u8>() { Ok(n) => ExitCode::from(n), Err(_) => ExitCode::FAILURE, }, None => ExitCode::FAILURE, }}$ cargo run -q -- 7$ echo $?7
$ cargo run -q -- abc$ echo $?1ExitCode::from takes a u8, so 300 doesn’t fit and the parse falls back to ExitCode::FAILURE, which is one. This program prints nothing either way; the exit status is the whole output.
The codes you will meet
Section titled “The codes you will meet”fn main() { let code: i32 = std::env::args() .nth(1) .and_then(|c| c.parse().ok()) .unwrap_or(0); std::process::exit(code);}$ cargo run -q -- 300$ echo $?44On Unix the status is eight bits wide, so 300 comes back as 44. Four things set it: returning () from main gives 0; returning Err from main gives 1 and prints Error: … on stderr; a panic gives 101; and process::exit(n) gives n, truncated as above.
fn main() { panic!("boom");}$ cargo run -q
thread 'main' (7229093) panicked at src/main.rs:2:5:boomnote: run with `RUST_BACKTRACE=1` environment variable to display a backtrace$ echo $?101The number in the panic line is a thread id and changes from run to run.
One thing exit does not skip: stdout is flushed before the process ends, so a print! with no trailing newline still arrives. The C folklore about losing buffered output does not apply here. What exit costs you is the destructors, not the bytes.
- How to read standard input until the end — the chapter’s previous step, ending where this one starts
- How to write to a file — the
BufWriterwhose flushprocess::exitskips - Back to Building a command-line tool
Colouring terminal output is the next step in this chapter.