How to handle errors in main function

Return a Result type from main and use the ? operator to propagate errors gracefully instead of panicking.

When main fails, don't panic

You wrote a Rust script to parse a configuration file. It works perfectly on your machine. You send it to a teammate, and their terminal explodes with a panic backtrace because the file path was slightly different. Or you're building a CLI tool that fetches data, and a network timeout turns your clean exit into a stack dump. The default main function in Rust assumes everything works. When it doesn't, the program panics. That's fine for prototypes, but production code needs to fail gracefully.

Rust gives you a better option. The main function can return a Result. When it does, the runtime checks the return value. If it's Ok, the program exits with code 0. If it's Err, the runtime prints the error message to stderr and exits with a non-zero code. This turns your error handling into a clean exit instead of a crash.

How main returns Result

By default, main looks like this:

fn main() {
    println!("Hello, world!");
}

It returns nothing. If something goes wrong, you have to call panic! or use .unwrap(), which stops the program and prints a stack trace. But main can also return a Result:

use std::fs;
use std::error::Error;

fn main() -> Result<(), Box<dyn Error>> {
    // Return Result from main so errors exit gracefully.
    // Box<dyn Error> accepts any error type, avoiding type mismatches.
    let contents = fs::read_to_string("config.txt")?;

    // The ? operator returns early if read_to_string fails.
    // It converts the error into the Box<dyn Error> type automatically.
    println!("Config loaded: {} bytes", contents.len());

    // Explicit Ok(()) signals success.
    // The runtime exits with code 0.
    Ok(())
}

When you compile this, the compiler sees main returns a Result. It wraps your main in a hidden function that calls your real main and checks the return value. At runtime, if fs::read_to_string fails, the ? operator catches the error. It converts the specific std::io::Error into a Box<dyn Error> and returns it immediately. Your main function ends. The runtime receives the Err, prints the error description to stderr, and exits with code 1. No backtrace. Just the error message.

Reach for Result in main whenever your program interacts with the outside world. Files, networks, user input, and environment variables can all fail. Returning Result lets you handle those failures without boilerplate.

The magic of ? and trait objects

The ? operator does more than just return errors. It converts them. This is the key to why Box<dyn Error> works so well in main.

When you use ? on a Result<T, E>, the operator checks the value. If it's Ok, it extracts the inner value. If it's Err, it returns the error from the current function. But before it returns, it calls From::from on the error to convert it to the function's error type.

This means ? can automatically convert between error types as long as there's a From implementation. The standard library provides From<E> for Box<dyn Error> for any E that implements Error. That's why ? works seamlessly with Box<dyn Error>. You can mix io::Error, ParseIntError, and custom errors in the same function, and ? converts them all to Box<dyn Error> behind the scenes.

The Box<dyn Error> type is a trait object. dyn Error means "any type that implements the Error trait". Box allocates the error on the heap and stores a pointer to it. This makes all errors the same size, regardless of their original type. Trait objects use dynamic dispatch, which adds a tiny overhead. In main, this overhead is negligible. You're exiting the program anyway. Don't optimize main error handling. Use Box<dyn Error> for simplicity.

Convention aside: Box<dyn std::error::Error> is the idiomatic return type for main in the Rust ecosystem. You'll see it in almost every CLI tool and script. The community calls this the "catch-all error" pattern. It's the path of least resistance for top-level error handling.

Realistic error chaining

Real programs do more than one thing. You might read a file, parse JSON, and make a network request. Each step can fail. Using ? chains them together.

use std::fs;
use std::error::Error;

fn main() -> Result<(), Box<dyn Error>> {
    // Chain fallible operations using the ? operator.
    // Each ? returns the error early if the operation fails.
    let data = fs::read_to_string("number.txt")?;

    // parse() returns a Result<i32, ParseIntError>.
    // The ? operator converts ParseIntError to Box<dyn Error>.
    let number: i32 = data.trim().parse()?;

    println!("The number is {}", number);
    Ok(())
}

If read_to_string fails, the error propagates and main exits. If parsing fails, the error propagates and main exits. The code reads like a happy path, but it handles errors correctly. This is the power of ?. It lets you write linear code while propagating errors automatically.

Trust the error types. They carry the context you need. When main returns an error, the runtime prints the error message. That message comes from the Display implementation of the error. Most standard library errors provide helpful messages. io::Error prints "No such file or directory". ParseIntError prints "invalid digit found in string". Users see these messages, not stack traces.

Pitfalls and compiler errors

Returning Result from main is simple, but there are a few gotchas.

If you return a specific error type like Result<(), std::io::Error>, you can only use ? with io::Error. If you try to use ? with a different error type, the compiler rejects you with E0308 (mismatched types). main expects Result<(), std::io::Error>, but ? returns Result<i32, ParseIntError>. The fix is to use Box<dyn Error>, which accepts any error type.

The error type in main must implement Debug. The runtime prints the error using {:?}. If you return a custom error type without Debug, the compiler rejects you with E0277 (trait bound not satisfied). The error type must implement Debug. Always derive Debug on your custom error types.

#[derive(Debug)]
struct MyError {
    message: String,
}

impl std::fmt::Display for MyError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.message)
    }
}

impl std::error::Error for MyError {}

This error type implements Debug, Display, and Error. It works with Box<dyn Error> and prints correctly in main.

Sometimes you need custom exit codes. Result only gives you 0 for success and 1 for error. If you want exit code 2 for bad usage, you can use std::process::exit.

use std::process;

fn main() {
    // Check command line arguments.
    // If usage is wrong, exit with code 2.
    if std::env::args().len() < 2 {
        eprintln!("Usage: program <arg>");
        process::exit(2);
    }

    // Continue with normal logic.
    println!("Argument: {}", std::env::args().nth(1).unwrap());
}

Warning: process::exit terminates the program immediately. It does not run destructors. If you have resources that need cleanup, don't use exit. Return an error instead and let the runtime handle the exit. Use exit only when you need a specific exit code and you're sure no cleanup is needed.

Don't fight the compiler here. Reach for Box<dyn Error> in main. It's the standard pattern for a reason.

Decision: when to use each pattern

Use main returning () when your program cannot fail. This is rare. Only use it for infinite loops, simple prints, or when you handle every error internally with if let or match.

Use main returning Result<(), Box<dyn Error>> for scripts, CLI tools, and prototypes. This is the standard pattern. It lets you use ? everywhere without worrying about error type conversions.

Use main returning Result<(), CustomError> when you need to map errors to specific exit codes or structured output. This requires more boilerplate but gives you full control over how errors are reported.

Use panic! or .unwrap() only in tests or when a failure is truly unrecoverable and indicates a bug in your logic, not user input. Never use .unwrap() on user-facing operations.

Counter-intuitive but true: returning Result from main makes your code safer and shorter than handling errors manually. The ? operator eliminates boilerplate and ensures errors propagate correctly.

Where to go next