Skip to content
Select theme

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.

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 -q
usage: exit-code <name>
$ echo $?
2
$ cargo run -q -- ada
hello, ada
$ echo $?
0

process::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.

$ cargo run -q
before exit
$ echo $?
0

guard 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 -q
before return
guard dropped
$ echo $?
0

That 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.

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 $?
1

ExitCode::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.

$ cargo run -q -- 300
$ echo $?
44

On 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.

$ cargo run -q
thread 'main' (7229093) panicked at src/main.rs:2:5:
boom
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
$ echo $?
101

The 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.

Colouring terminal output is the next step in this chapter.