Handling Errors in main()

How to return recoverable errors from Rust's entry point using the Result type, the ? operator, and custom error handling for correct exit codes and error messages.

The main function is where your program starts. By default it returns nothing—just (). But Rust lets you declare main to return a Result, which changes how errors flow out of your application. Instead of panicking or printing an error and calling process::exit manually, you can propagate errors all the way up with ? and let the runtime handle the final reporting.

What Returning a Result from main Does

A main function that returns Result<(), E> behaves like this: if the function evaluates to Ok(()), the program exits with a zero status code (success). If it evaluates to Err(e), Rust prints the Debug representation of the error to standard error and exits with a non‑zero status code (specifically 1). No manual eprintln! or process::exit is needed.

This is the simplest possible error‑handling main:

src/main.rs
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let content = std::fs::read_to_string("config.toml")?;
    println!("Config loaded: {content}");
    Ok(())
}

If config.toml does not exist, the ? operator converts the std::io::Error into a Box<dyn Error> (thanks to the blanket From implementation) and returns it early. The runtime catches the error, prints the Debug output—something like Os { code: 2, kind: NotFound, message: "No such file or directory" }—to stderr, and exits with code 1.

Everything Works:

When no error occurs and main returns Ok(()), the process exits silently with code 0. This is the normal, expected outcome.

The key insight for beginners: returning a Result from main turns the whole program into a fallible operation. Any function that returns a Result can be called with ? inside main, and errors bubble up to a single exit point without boilerplate.

Why Not Just Panic?

You could handle errors inside main with match or .expect(), and that works. But using Result as the return type has distinct advantages:

  • No stack unwinding or panic messages — the error message is controlled by the Debug output of your error type.
  • Cleaner code — you can chain multiple fallible operations with ? without deeply nested match blocks.
  • Callers see a real exit code — a panic prints a large backtrace and exits with code 101, whereas a returned Err prints a concise diagnostic and exits with 1 (or a custom code you choose).

For a command‑line tool, the concise error report is almost always preferable to a panic dump.

Panic vs. Return:

panic! is for unrecoverable programmer errors, like internal invariant violations. Recoverable I/O failures, user input errors, or network timeouts should be returned, not panicked on. Returning an error from main keeps the boundary clear.

Choosing the Error Type for main

The standard library’s std::error::Error trait requires both Debug and Display. When main returns Err, the runtime uses Debug to print the error. So whatever concrete error type you use must implement Debug. Beyond that, three patterns dominate real‑world Rust programs.

The simplest approach, built entirely on the standard library.

src/main.rs
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let file = std::fs::File::open("data.csv")?;
    // ... process file
    Ok(())
}

Any error that implements std::error::Error can be converted into a Box<dyn Error> through the blanket From impl, so ? works automatically. No extra dependencies.

The downside: Box<dyn Error> is an unsized trait object. You cannot easily match on specific error variants downstream, and adding context (like “failed to open config”) requires manual wrapping. It’s fine for tiny scripts but rarely scales well.

Debug is Required:

If your custom error type does not implement Debug, the compiler will refuse the program with a message like the trait Debug is not implemented for AppError. The runtime prints {:#?} on error, so Debug is non‑negotiable.

How the Runtime Handles the Error

The behavior is defined in the standard library. When the Rust runtime starts, it calls your main. If main returns Ok(()), everything is fine. If it returns Err(e), the runtime does roughly this:

  1. Calls Debug::fmt(&e, &mut fmt::Formatter::new(...)) and writes the result to stderr.
  2. Calls std::process::exit(1).

That means you get one line of error output and a standard failure exit code. The error message is the Debug representation, which for anyhow::Error includes the chain of contexts and causes.

Why Not Display?:

The runtime uses Debug, not Display. This is an intentional design choice: Display output is meant for end users and might be formatted in a way that is not suitable for a log line (e.g., single‑line or multi‑line?). Debug is unambiguous and machine‑friendly.

Customizing the Exit Code and Error Message

The automatic behavior prints a Debug dump and exits with 1. That may not be what you want—different failure modes might need different exit codes, or you might prefer a human‑readable error message.

The cleanest way to gain control is to create an inner “real main” that returns a Result, then handle the result in a thin wrapper:

src/main.rs
fn real_main() -> Result<(), Box<dyn std::error::Error>> {
    // all your logic with ? here
    let data = std::fs::read_to_string("input.txt")?;
    println!("{data}");
    Ok(())
}
fn main() {
    if let Err(e) = real_main() {
        eprintln!("Application error: {e}");
        std::process::exit(1);
    }
}

Now you can inspect e with downcast_ref to decide on a specific exit code, or format the error with Display instead of Debug. For instance, with anyhow, you could write:

src/main.rs
fn main() {
    if let Err(e) = real_main() {
        eprintln!("Error: {e:#}"); // pretty-print the full chain
        std::process::exit(1);
    }
}

This approach gives you total control while still letting real_main benefit from ? propagation. Many CLI frameworks (like clap) do exactly this internally.

Common Mistakes When Using Result in main

Forgetting That the Runtime Uses Debug, Not Display

A custom error type with a beautiful Display impl but no Debug impl will not compile. Conversely, if you only have Debug but no Display, the error will print as a debug dump even when you’d prefer a user‑friendly message. Always implement both traits.

Missing Debug Causes a Compile Error:

If you write fn main() -> Result<(), MyError> and MyError does not implement Debug, you will see:

error[E0277]: MyErrordoesn't implementDebug``

The runtime needs Debug to print the error, so this is a hard requirement.

Expecting the OS to See a Non‑Zero Exit Code Without Returning an Error

Calling eprintln!("something failed"); does not change the exit code. The program still exits with 0 unless you call std::process::exit(code) explicitly. Returning Err from main is the idiomatic way to signal failure.

Mixing panicking code with Result‑returning main

If any code path panics and you do not catch it with std::panic::catch_unwind, the process exits with code 101 and prints a backtrace, regardless of what your main returns. That makes the panic the dominant failure mode. Reserve panics for truly unrecoverable situations (see the earlier section on panic!).

Using a Non‑Unit Ok Variant

The successful variant of main's return type must be (). Returning Result<i32, E> or similar will not work. The compiler expects exactly Result<(), E> for the standard runtime entry point.

Only () Allowed as Success:

fn main() -> Result<i32, E> is invalid. The Rust runtime has no use for a success value; it only needs to know if something went wrong.

A Complete End‑to‑End Example

The following program reads a CSV file, sums a column, and writes the result to another file. All errors bubble up to main and are printed with context.

src/main.rs
use anyhow::{Context, Result};
use std::io::{BufRead, BufReader, Write};
fn process_csv(input: &str, output: &str) -> Result<()> {
    let in_file = std::fs::File::open(input)
        .context("cannot open input file")?;
    let reader = BufReader::new(in_file);
    let mut total = 0.0f64;
    for line in reader.lines() {
        let line = line.context("failed to read line")?;
        let value: f64 = line
            .trim()
            .parse()
            .context("failed to parse number")?;
        total += value;
    }
    let mut out_file = std::fs::File::create(output)
        .context("cannot create output file")?;
    writeln!(out_file, "{total}")
        .context("failed to write output")?;
    Ok(())
}
fn main() -> Result<()> {
    process_csv("numbers.csv", "result.txt")?;
    println!("Done. Total written to result.txt");
    Ok(())
}

Run with cargo run. If numbers.csv contains valid numbers, you see Done. Total written to result.txt and the exit code is 0. If the file is missing, the program prints:

Error: cannot open input file
Caused by:
    No such file or directory (os error 2)

and exits with code 1. The error chain from .context() is clearly visible, making debugging straightforward.

Summary

Returning Result from main is the standard way to let errors flow from any depth of your program straight to the OS without manual wiring. It transforms the entry point into a single fallible function that can use ? anywhere. For most applications, pair anyhow::Result with .context() calls to build error reports that tell you both what went wrong and where. When you need specific exit codes or formatted user messages, separate the logic into a real_main() and handle the error in the wrapper.