Skip to main content
Rust · lesson 16 of 30
Rust intermediate Lesson 16 of 30

Error Handling in Rust

Handle errors in Rust with Result, the ? operator, custom error types, thiserror and anyhow — each example run for real, including the failures.

By SkillByExample · Updated

The Two Strategies: panic! and Result

Rust draws a clear line between two kinds of failure. A panic! signals a bug — something that should never happen in correct code, like an out-of-bounds access or a violated invariant. A Result signals an expected failure — something that can go wrong in normal operation, like a file not found or a network timeout. Separating these means callers always know whether a function can fail in a recoverable way, and the compiler enforces that they handle it.

// panic! — unrecoverable, crashes the thread immediately
fn divide_unsafe(a: i32, b: i32) -> i32 {
    if b == 0 { panic!("division by zero!"); }
    a / b
}

// Result — recoverable, caller decides what to do with the error
fn divide(a: i32, b: i32) -> Result<i32, String> {
    if b == 0 { Err(String::from("division by zero")) }
    else { Ok(a / b) }
}

fn main() {
    match divide(10, 2) {
        Ok(n)  => println!("{}", n),
        Err(e) => eprintln!("Error: {}", e),
    }

    match divide(10, 0) {
        Ok(n)  => println!("{}", n),
        Err(e) => eprintln!("Error: {}", e),
    }

    println!("still running");
    println!("{}", divide_unsafe(10, 0));
    println!("never printed");
}
$ rustc error_handling_the_two_strategies_panic_and_result.rs && ./error_handling_the_two_strategies_panic_and_result
5
Error: division by zero
still running

thread 'main' (6320) panicked at error_handling_the_two_strategies_panic_and_result.rs:3:17:
division by zero!
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

The Result version reports the problem and the program carries on to still running. divide_unsafe(10, 0) panics instead: the thread unwinds, never printed never prints, and the process exits with a non-zero status. (The number after 'main' is the OS thread ID and differs between runs.)

The ? Operator

Propagating errors manually with match on every fallible call quickly becomes verbose. The ? operator is syntactic sugar that does the propagation for you: it extracts the Ok value if the result succeeded, and if it failed it converts the error (using the From trait) and returns early. This lets you write a chain of fallible operations as if they were sequential, with errors handled implicitly.

use std::fs;

fn read_number_from_file(path: &str) -> Result<i32, Box<dyn std::error::Error>> {
    let content = fs::read_to_string(path)?; // propagates io::Error if file missing
    let n: i32 = content.trim().parse()?;    // propagates ParseIntError if not a number
    Ok(n * 2)
}

fn main() {
    fs::write("good.txt", "21\n").unwrap();
    fs::write("bad.txt", "twenty-one").unwrap();

    for path in ["good.txt", "bad.txt", "missing.txt"] {
        match read_number_from_file(path) {
            Ok(n) => println!("{}: {}", path, n),
            Err(e) => println!("{}: error: {}", path, e),
        }
    }
}
$ rustc error_handling_the_operator.rs && ./error_handling_the_operator
good.txt: 42
bad.txt: error: invalid digit found in string
missing.txt: error: The system cannot find the file specified. (os error 2)

One function, two different error types — io::Error from the read, ParseIntError from the parse — both funnelled into Box<dyn Error> by ?. The missing.txt message is the operating system’s own text, so it reads differently on Linux (No such file or directory (os error 2)).

? expands to approximately this match:

use std::fs;

fn read_len(path: &str) -> Result<usize, Box<dyn std::error::Error>> {
    let content = match fs::read_to_string(path) {
        Ok(v) => v,
        Err(e) => return Err(e.into()), // .into() converts via the From trait
    };
    Ok(content.len())
}

fn main() {
    fs::write("hello.txt", "hello").unwrap();
    println!("{:?}", read_len("hello.txt").ok());
    println!("{:?}", read_len("missing.txt").is_err());
}
$ rustc error_handling_the_operator_2.rs && ./error_handling_the_operator_2
Some(5)
true

? also works with Option — it returns None early instead of Err:

fn first_char(s: &str) -> Option<char> {
    let c = s.chars().next()?;             // returns None if string is empty
    Some(c.to_uppercase().next()?)
}

fn main() {
    println!("{:?}", first_char("hello"));
    println!("{:?}", first_char(""));
}
$ rustc error_handling_the_operator_3.rs && ./error_handling_the_operator_3
Some('H')
None

Handling Result Without ?

When you need fine-grained control over what to do with each outcome, the Result type has a rich set of methods for transforming, chaining, and providing defaults:

fn main() {
    let r: Result<i32, &str> = Ok(42);

    // match — full control over both branches
    match r { Ok(n) => println!("{}", n), Err(e) => println!("{}", e) }

    // unwrap — panics on Err (use only in tests or when Err is truly impossible)
    println!("{}", r.unwrap());

    // expect — panics with a descriptive message (better than unwrap in tests)
    println!("{}", r.expect("should have a number"));

    // unwrap_or — provide a fallback value
    println!("{}", r.unwrap_or(0));

    // unwrap_or_else — compute the fallback lazily
    println!("{}", r.unwrap_or_else(|_| -1));

    // map — transform the Ok value, pass Err through unchanged
    println!("{:?}", r.map(|n| n * 2));

    // map_err — transform the Err value, pass Ok through unchanged
    let e: Result<i32, i32> = Err(5);
    println!("{:?}", e.map_err(|n| n * 2));

    // and_then — chain a second fallible operation
    let result = r.and_then(|n| if n > 0 { Ok(n) } else { Err("negative") });
    println!("{:?}", result);
}
$ rustc error_handling_handling_result_without.rs && ./error_handling_handling_result_without
42
42
42
42
42
Ok(84)
Err(10)
Ok(42)

Five 42s in a row is the point: on an Ok, every one of these accessors agrees. They only differ on the Err path — unwrap and expect panic, unwrap_or and unwrap_or_else substitute a value.

Defining Custom Error Types

For library code, a string error type like String loses information and makes programmatic error handling impossible for callers. A custom error enum lets callers match on specific variants, access structured data, and understand the error chain. The std::error::Error trait and Display implementation make your error work with the broader ecosystem.

use std::fmt;
use std::num::ParseIntError;

#[derive(Debug)]
enum AppError {
    Parse(ParseIntError),
    OutOfRange { value: i32, min: i32, max: i32 },
    NotFound(String),
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            AppError::Parse(e) => write!(f, "parse error: {}", e),
            AppError::OutOfRange { value, min, max } =>
                write!(f, "{} is out of range [{}, {}]", value, min, max),
            AppError::NotFound(key) => write!(f, "'{}' not found", key),
        }
    }
}

impl std::error::Error for AppError {
    // source() lets callers traverse the error chain
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            AppError::Parse(e) => Some(e),
            _ => None,
        }
    }
}

// Allow ? to convert ParseIntError into AppError automatically
impl From<ParseIntError> for AppError {
    fn from(e: ParseIntError) -> Self {
        AppError::Parse(e)
    }
}

fn parse_port(s: &str) -> Result<u16, AppError> {
    let n: i32 = s.parse()?; // ParseIntError → AppError via From impl above
    if n < 1 || n > 65535 {
        return Err(AppError::OutOfRange { value: n, min: 1, max: 65535 });
    }
    Ok(n as u16)
}

fn find_setting(key: &str) -> Result<&'static str, AppError> {
    match key {
        "port" => Ok("8080"),
        _ => Err(AppError::NotFound(key.to_string())),
    }
}

fn main() {
    for input in ["8080", "99999", "http"] {
        match parse_port(input) {
            Ok(p)  => println!("port: {}", p),
            Err(e) => {
                // source() is Some only for the variant that wraps another error
                let cause = std::error::Error::source(&e).map(|s| s.to_string());
                println!("error: {} (source: {:?})", e, cause);
            }
        }
    }

    match find_setting("host") {
        Ok(v) => println!("found {}", v),
        Err(e) => println!("error: {}", e),
    }
}
$ rustc error_handling_defining_custom_error_types.rs && ./error_handling_defining_custom_error_types
port: 8080
error: 99999 is out of range [1, 65535] (source: None)
error: parse error: invalid digit found in string (source: Some("invalid digit found in string"))
error: 'host' not found

"http" fails inside s.parse(); ? converts the ParseIntError into AppError::Parse through the From impl, and source() hands the original error back. OutOfRange and NotFound are errors this code created itself, so they have no source.

Using thiserror for Cleaner Custom Errors

Writing Display, Error, and From implementations by hand is repetitive boilerplate. The thiserror crate generates all of it from annotations on your enum. This is the standard choice for library crates that need structured, typed errors without the ceremony.

[dependencies]
thiserror = "1"
use thiserror::Error;
use std::num::ParseIntError;

#[derive(Debug, Error)]
enum AppError {
    // #[error("...")] generates the Display impl
    // #[from] generates the From impl and wires it to ?
    #[error("parse error: {0}")]
    Parse(#[from] ParseIntError),

    #[error("{value} is out of range [{min}, {max}]")]
    OutOfRange { value: i32, min: i32, max: i32 },
}

fn parse_port(s: &str) -> Result<u16, AppError> {
    let n: i32 = s.parse()?; // auto-converts via #[from] on Parse variant
    if !(1..=65535).contains(&n) {
        return Err(AppError::OutOfRange { value: n, min: 1, max: 65535 });
    }
    Ok(n as u16)
}

fn main() {
    for input in ["8080", "99999", "http"] {
        match parse_port(input) {
            Ok(p) => println!("port: {}", p),
            Err(e) => println!("error: {}  |  {:?}", e, e),
        }
    }
}

Put that in src/main.rs of a Cargo project with the dependency above:

$ cargo run -q
port: 8080
error: 99999 is out of range [1, 65535]  |  OutOfRange { value: 99999, min: 1, max: 65535 }
error: parse error: invalid digit found in string  |  Parse(ParseIntError { kind: InvalidDigit })

The Display text (left of |) comes from the #[error(...)] strings; the Debug text (right) from #[derive(Debug)]. Every hand-written impl from the previous section is gone, and the behaviour is the same.

Using anyhow for Application Code

Library crates need typed errors so callers can react programmatically. Application code (binaries, CLI tools, scripts) usually just needs to display errors clearly and propagate them to main. The anyhow crate provides a single Error type that wraps any error, plus a .context() method that attaches human-readable explanation at each layer of the call stack.

[dependencies]
anyhow = "1"
use anyhow::{Context, Result, bail};

fn read_config(path: &str) -> Result<String> {
    // .with_context() adds a message that appears when the error is printed
    let content = std::fs::read_to_string(path)
        .with_context(|| format!("failed to read config from {}", path))?;

    if content.is_empty() {
        // bail! is shorthand for return Err(anyhow!(...))
        bail!("config file is empty");
    }

    Ok(content)
}

fn main() -> Result<()> {
    let config = read_config("config.toml")
        .context("application startup failed")?;

    println!("config loaded: {} bytes", config.len());
    Ok(())
}

Run it in a directory with no config.toml:

$ cargo run -q
Error: application startup failed

Caused by:
    0: failed to read config from config.toml
    1: The system cannot find the file specified. (os error 2)

Returning Err from main prints the outermost context first, then each layer underneath as a numbered Caused by: list, and exits with status 1. The last line is the OS error text — this run was on Windows; on Linux it reads No such file or directory (os error 2).

Combining thiserror and anyhow

The two crates are complementary and designed to be used together in larger projects. Use thiserror in library crates to give callers structured errors they can match on. Use anyhow in binary crates to accept errors from multiple libraries, wrap them with context, and present them to the user.

// In a real project DbError would live in a library crate's lib.rs
#[derive(Debug, thiserror::Error)]
pub enum DbError {
    #[error("connection failed: {0}")]
    Connection(String),
    #[error("query failed: {0}")]
    Query(String),
}

fn connect_db(url: &str) -> Result<(), DbError> {
    if url.starts_with("postgres://") {
        Err(DbError::Query(String::from("relation \"users\" does not exist")))
    } else {
        Err(DbError::Connection(format!("unsupported url '{}'", url)))
    }
}

// Application code: anyhow accepts DbError (or any other std::error::Error)
use anyhow::{Context, Result};

fn run(url: &str) -> Result<()> {
    connect_db(url).with_context(|| format!("while starting against {}", url))?;
    Ok(())
}

fn main() {
    for url in ["mysql://db", "postgres://db"] {
        if let Err(e) = run(url) {
            // {:#} prints the whole context chain on one line
            println!("{:#}", e);
            // the typed error is still recoverable underneath the context
            if let Some(DbError::Query(q)) = e.downcast_ref::<DbError>() {
                println!("  -> retryable query error: {}", q);
            }
        }
    }
}

With both crates in [dependencies]:

$ cargo run -q
while starting against mysql://db: connection failed: unsupported url 'mysql://db'
while starting against postgres://db: query failed: relation "users" does not exist
  -> retryable query error: relation "users" does not exist

DbError converts into anyhow::Error without any From impl of your own, and downcast_ref recovers the original typed error even after context has been layered on top — so application code can still branch on a specific library failure when it needs to.

Error Handling Summary

ToolUse case
panic!Bugs, broken invariants, “can’t happen”
Result<T, E>All recoverable errors
? operatorPropagate errors up the stack
unwrap / expectPrototyping, tests, when None/Err is truly impossible
Custom enum + thiserrorLibrary errors with structured variants
anyhowApplication code, scripts, main functions

Practice

  1. Put 1500000000 in a file and pass it to read_number_from_file. It parses fine as an i32 — so what happens?

    Solution

    It panics on the n * 2, not the parse:

    thread 'main' (25256) panicked at error_handling_overflow.rs:6:8:
    attempt to multiply with overflow

    A plain rustc build is a debug build, which checks arithmetic overflow and panics. Make the doubling itself fallible so it flows through ? like every other error:

    n.checked_mul(2).ok_or_else(|| format!("{} is too large to double", n).into())

    The function now returns Err("1500000000 is too large to double"). (A file containing 2147483648 never gets that far — it fails the parse with number too large to fit in target type.)

  2. Delete the impl From<ParseIntError> for AppError block from the custom error example and write the parse as s.parse::<i32>()?. What does the compiler say?

    Solution
    error[E0277]: `?` couldn't convert the error to `AppError`
    ...
      = note: the question mark operation (`?`) implicitly performs a conversion on the error value using the `From` trait

    ? is not magic: it calls From::from on the error. No From impl, no ?. (Without the ::<i32> turbofish you get a more confusing E0271 instead, because the compiler tries to infer the parse target from the error type.)

  3. In the anyhow example, create an empty config.toml and run again. What does the error chain look like now?

    Solution
    Error: application startup failed
    
    Caused by:
        config file is empty

    The read succeeded, so there is no OS error at the bottom of the chain; bail! created the innermost error and .context(...) wrapped it.

  4. Replace {:#} with {} in the combined example’s println!. What disappears?

    Solution

    Only the outermost context prints — while starting against mysql://db — and the DbError text is dropped. {:#} walks the whole chain onto one line; {:?} would print the multi-line Caused by: form that main uses.

Frequently Asked Questions

When should I use panic! vs Result?
Use panic! for unrecoverable bugs (programming errors, broken invariants). Use Result for expected failures that callers should handle (IO errors, parse errors, network failures).
What does the ? operator do?
? unwraps Ok(v) to v, and for Err(e) it converts the error using From and returns early from the current function. It only works in functions returning Result or Option.
What is the difference between thiserror and anyhow?
thiserror is for library authors — it generates Display and Error implementations for custom error types. anyhow is for applications — it provides a single error type that can hold any error with context.