ZetCode

Rust error handling

last modified October 25, 2026

In this article we show how to handle errors in Rust.

Rust has no exceptions. It divides errors into two groups. Unrecoverable errors are bugs, such as an index past the end of an array; they stop the program with a panic. Recoverable errors, such as a missing file or invalid user input, are expected to happen and are returned as values of the Result<T, E> type.

enum Result<T, E> {
    Ok(T),
    Err(E),
}

A function that can fail returns Ok with the value on success, or Err with an error. Because the error is part of the return type, the caller cannot forget about it: the value is accessible only after the Result has been checked.

The examples were tested with Rust 1.97 and the 2024 edition. They are run as Cargo projects with cargo run -q.

Panics

A panic happens when the program reaches a state it cannot handle. The standard library panics, for example, on out-of-bounds indexing.

main.rs
fn main() {
    let vals: Vec<i32> = (1..=3).collect();
    let idx = 5;

    println!("accessing element {idx}");
    let v = vals[idx];

    println!("value: {v}");
}

We access an element past the end of a vector.

let v = vals[idx];

The indexing operator panics, because the vector has only three elements. The panic message with its source location is printed to standard error, the stack is unwound, and the program terminates with exit code 101. The last println! is never reached.

The number in parentheses after the thread name is the thread ID; it differs between runs.

$ cargo run -q
accessing element 5

thread 'main' (35831) panicked at src/main.rs:6:17:
index out of bounds: the len is 3 but the index is 5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
$ echo $?
101

The panic! macro

Our own code can panic with the panic! macro.

main.rs
fn average(vals: &[f64]) -> f64 {
    if vals.is_empty() {
        panic!("cannot compute the average of an empty slice");
    }
    vals.iter().sum::<f64>() / vals.len() as f64
}

fn main() {
    println!("{}", average(&[2.0, 4.0, 9.0]));
    println!("{}", average(&[]));
}

The average function panics when it gets an empty slice.

if vals.is_empty() {
    panic!("cannot compute the average of an empty slice");
}

The macro takes a message in the same format as println!. A panic is appropriate when the caller has broken the function's contract, that is, for a programming error. Errors that the caller can reasonably expect and handle should be returned as a Result instead.

$ cargo run -q
5

thread 'main' (35849) panicked at src/main.rs:3:9:
cannot compute the average of an empty slice
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

The Result type

Many standard functions return a Result. String parsing is a typical example.

main.rs
fn main() {
    let inputs = ["42", "-17", "3.14", "abc", "99999999999"];

    for s in inputs {
        let res: Result<i32, _> = s.parse::<i32>();

        match res {
            Ok(n) => println!("{s:>12} -> {n}"),
            Err(e) => println!("{s:>12} -> error: {e}"),
        }
    }
}

We parse several strings into integers.

let res: Result<i32, _> = s.parse::<i32>();

The parse method returns Result<i32, ParseIntError>.

match res {
    Ok(n) => println!("{s:>12} -> {n}"),
    Err(e) => println!("{s:>12} -> error: {e}"),
}

A match handles both outcomes. The error value implements Display and describes what went wrong.

$ cargo run -q
          42 -> 42
         -17 -> -17
        3.14 -> error: invalid digit found in string
         abc -> error: invalid digit found in string
 99999999999 -> error: number too large to fit in target type

Returning Result from a function

Our own functions report failures by returning a Result.

main.rs
fn sqrt_checked(x: f64) -> Result<f64, String> {
    if x < 0.0 {
        Err(format!("cannot take the square root of {x}"))
    } else {
        Ok(x.sqrt())
    }
}

fn main() {
    for x in [16.0, 2.0, -4.0] {
        match sqrt_checked(x) {
            Ok(r) => println!("sqrt({x}) = {r:.4}"),
            Err(msg) => println!("error: {msg}"),
        }
    }

    if let Err(e) = sqrt_checked(-1.0) {
        println!("failed: {e}");
    }

    println!("{}", sqrt_checked(9.0).is_ok());
}

The sqrt_checked function refuses negative numbers.

fn sqrt_checked(x: f64) -> Result<f64, String> {
    if x < 0.0 {
        Err(format!("cannot take the square root of {x}"))
    } else {
        Ok(x.sqrt())
    }
}

The error type can be any type. A String is the simplest choice for small programs; later we show dedicated error types that callers can inspect.

if let Err(e) = sqrt_checked(-1.0) {
    println!("failed: {e}");
}

When only one outcome is interesting, if let is shorter than a match. The is_ok and is_err methods just test the variant.

$ cargo run -q
sqrt(16) = 4.0000
sqrt(2) = 1.4142
error: cannot take the square root of -4
failed: cannot take the square root of -1
true

unwrap, expect, and defaults

Several methods extract the value from a Result without a match.

main.rs
fn main() {
    let a: i32 = "12".parse().unwrap();
    let b: i32 = "7".parse().expect("b must be a number");
    println!("{}", a + b);

    let c: i32 = "x".parse().unwrap_or(0);
    let d: i32 = "".parse().unwrap_or_default();
    let e: i32 = "y".parse().unwrap_or_else(|err| {
        println!("using fallback because: {err}");
        -1
    });
    println!("{c} {d} {e}");

    let port: u16 = "70000".parse().expect("port must be between 0 and 65535");
    println!("{port}");
}

We use different strategies for the error case.

let a: i32 = "12".parse().unwrap();
let b: i32 = "7".parse().expect("b must be a number");

The unwrap method returns the value or panics on an error; expect does the same with our own message. They are useful in examples, tests, and in cases where an error would be a bug.

let c: i32 = "x".parse().unwrap_or(0);
let d: i32 = "".parse().unwrap_or_default();

unwrap_or returns a fallback value on error and unwrap_or_default returns the default value of the type. unwrap_or_else computes the fallback with a closure, which receives the error.

let port: u16 = "70000".parse().expect("port must be between 0 and 65535");

The value does not fit into a u16, so expect panics. The message is followed by the Debug representation of the error. More on these methods is in Rust unwrap function.

$ cargo run -q
19
using fallback because: invalid digit found in string
0 0 -1

thread 'main' (35882) panicked at src/main.rs:14:37:
port must be between 0 and 65535: ParseIntError { kind: PosOverflow }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

The ? operator

The ? operator propagates errors to the caller. It is the most common way of handling errors in Rust functions.

main.rs
use std::num::ParseIntError;

fn sum_pair(s: &str) -> Result<i32, ParseIntError> {
    let (a, b) = s.split_once(',').unwrap_or((s, "0"));
    let x: i32 = a.trim().parse()?;
    let y: i32 = b.trim().parse()?;
    Ok(x + y)
}

fn main() {
    for s in ["3, 4", "10,-2", "8", "5, five"] {
        match sum_pair(s) {
            Ok(v) => println!("{s:?} -> {v}"),
            Err(e) => println!("{s:?} -> error: {e}"),
        }
    }
}

The sum_pair function parses two comma-separated numbers.

let x: i32 = a.trim().parse()?;
let y: i32 = b.trim().parse()?;
Ok(x + y)

If parse returns Ok, the ? operator unwraps the value. If it returns Err, the function returns immediately with that error. The operator can be used only in functions whose return type is compatible, here Result<i32, ParseIntError>.

The line let x: i32 = a.trim().parse()?; is roughly equivalent to the following code:

let x: i32 = match a.trim().parse() {
    Ok(v) => v,
    Err(e) => return Err(From::from(e)),
};

The call to From::from converts the error into the error type of the function; we use this conversion later with custom error types.

$ cargo run -q
"3, 4" -> 7
"10,-2" -> 8
"8" -> 8
"5, five" -> error: invalid digit found in string

The ? operator with Option

The ? operator also works in functions that return Option. There, it returns None early.

main.rs
fn first_initial(full_name: &str) -> Option<char> {
    let last = full_name.split_whitespace().nth(1)?;
    let c = last.chars().next()?;
    Some(c.to_ascii_uppercase())
}

fn main() {
    println!("{:?}", first_initial("John doe"));
    println!("{:?}", first_initial("Plato"));

    let age = "abc".parse::<u32>().ok();
    println!("{:?}", age);

    let name: Option<&str> = None;
    let r: Result<&str, String> = name.ok_or(String::from("name is missing"));
    println!("{:?}", r);
}

The first_initial function returns the uppercased initial of the second word.

let last = full_name.split_whitespace().nth(1)?;
let c = last.chars().next()?;

If the name has no second word, the function returns None.

let age = "abc".parse::<u32>().ok();
let r: Result<&str, String> = name.ok_or(String::from("name is missing"));

The ? operator does not convert between Option and Result automatically. The ok method turns a Result into an Option, discarding the error, and ok_or turns an Option into a Result with the given error.

$ cargo run -q
Some('D')
None
None
Err("name is missing")

Returning Result from main

The main function can return a Result, which allows using ? in it.

main.rs
use std::fs;
use std::io;

fn main() -> Result<(), io::Error> {
    fs::write("words.txt", "sky\nblue\nrock\n")?;

    let content = fs::read_to_string("words.txt")?;
    println!("{} lines", content.lines().count());

    let other = fs::read_to_string("missing.txt")?;
    println!("{other}");

    Ok(())
}

The program writes a file, reads it back, and then tries to read a file that does not exist.

fn main() -> Result<(), io::Error> {

On success, main returns Ok(()). If it returns an Err, Rust prints the error with its Debug representation, prefixed with Error:, and the process exits with code 1.

let other = fs::read_to_string("missing.txt")?;

Reading the missing file fails, and the ? operator returns the error from main.

$ cargo run -q
3 lines
Error: Os { code: 2, kind: NotFound, message: "No such file or directory" }
$ echo $?
1

Box<dyn Error>

A function often calls operations that fail with different error types. The Box<dyn Error> type can hold any error.

main.rs
use std::error::Error;
use std::fs;

fn total(path: &str) -> Result<i64, Box<dyn Error>> {
    let content = fs::read_to_string(path)?;
    let mut sum = 0;

    for line in content.lines() {
        let n: i64 = line.trim().parse()?;
        sum += n;
    }

    if sum < 0 {
        return Err("the total must not be negative".into());
    }

    Ok(sum)
}

fn main() {
    fs::write("good.txt", "10\n20\n30\n").unwrap();
    fs::write("bad.txt", "10\ntwenty\n30\n").unwrap();
    fs::write("neg.txt", "10\n-50\n").unwrap();

    for path in ["good.txt", "bad.txt", "neg.txt", "none.txt"] {
        match total(path) {
            Ok(t) => println!("{path}: total {t}"),
            Err(e) => println!("{path}: error: {e}"),
        }
    }
}

The total function reads numbers from a file and sums them. It can fail with an I/O error, a parse error, or our own error.

fn total(path: &str) -> Result<i64, Box<dyn Error>> {

Box<dyn Error> is a trait object for any type that implements the std::error::Error trait. The ? operator converts both io::Error and ParseIntError into it.

return Err("the total must not be negative".into());

A string slice can be converted into a Box<dyn Error> with into. This is a quick way to create an ad hoc error.

This approach is convenient for applications and prototypes. Its drawback is that callers cannot easily find out which error happened; they get only a message. Libraries usually define their own error types.

$ cargo run -q
good.txt: total 60
bad.txt: error: invalid digit found in string
neg.txt: error: the total must not be negative
none.txt: error: No such file or directory (os error 2)

Custom error types

A custom error type, usually an enum, lets callers distinguish between failure cases.

main.rs
use std::error::Error;
use std::fmt;

#[derive(Debug)]
enum WithdrawError {
    InsufficientFunds { needed: u64, available: u64 },
    AccountLocked,
    ZeroAmount,
}

impl fmt::Display for WithdrawError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            WithdrawError::InsufficientFunds { needed, available } => {
                write!(f, "insufficient funds: need {needed}, have {available}")
            }
            WithdrawError::AccountLocked => write!(f, "the account is locked"),
            WithdrawError::ZeroAmount => write!(f, "amount must be greater than zero"),
        }
    }
}

impl Error for WithdrawError {}

struct Account {
    balance: u64,
    locked: bool,
}

impl Account {
    fn withdraw(&mut self, amount: u64) -> Result<u64, WithdrawError> {
        if self.locked {
            return Err(WithdrawError::AccountLocked);
        }
        if amount == 0 {
            return Err(WithdrawError::ZeroAmount);
        }
        if amount > self.balance {
            return Err(WithdrawError::InsufficientFunds {
                needed: amount,
                available: self.balance,
            });
        }
        self.balance -= amount;
        Ok(self.balance)
    }
}

fn main() {
    let mut acc = Account { balance: 100, locked: false };

    for amount in [30, 0, 90, 70] {
        match acc.withdraw(amount) {
            Ok(left) => println!("withdrew {amount}, left {left}"),
            Err(WithdrawError::InsufficientFunds { needed, .. }) if needed > 80 => {
                println!("{amount} is too much; try a smaller amount")
            }
            Err(e) => println!("error: {e}"),
        }
    }

    acc.locked = true;
    if let Err(e) = acc.withdraw(10) {
        println!("error: {e} ({e:?})");
    }
}

Withdrawing money from an account can fail for three reasons.

#[derive(Debug)]
enum WithdrawError {
    InsufficientFunds { needed: u64, available: u64 },
    AccountLocked,
    ZeroAmount,
}

Each failure is a variant, and variants can carry details about the error.

impl fmt::Display for WithdrawError {
...
impl Error for WithdrawError {}

An error type should implement Debug, Display, and the Error trait. All methods of Error have default implementations, so the impl block can be empty. Implementing Error makes the type usable with Box<dyn Error>.

Err(WithdrawError::InsufficientFunds { needed, .. }) if needed > 80 => {
    println!("{amount} is too much; try a smaller amount")
}

Callers can match on specific variants and react to them, here with a guard. Other errors are printed with their Display text.

$ cargo run -q
withdrew 30, left 70
error: amount must be greater than zero
90 is too much; try a smaller amount
withdrew 70, left 0
error: the account is locked (AccountLocked)

Converting errors with From

When a function returns a custom error type, the ? operator can convert other errors into it through the From trait.

main.rs
use std::fmt;
use std::num::{ParseFloatError, ParseIntError};

#[derive(Debug)]
enum ConfigError {
    MissingKey(String),
    BadInt(ParseIntError),
    BadFloat(ParseFloatError),
}

impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            ConfigError::MissingKey(k) => write!(f, "missing key '{k}'"),
            ConfigError::BadInt(e) => write!(f, "invalid integer: {e}"),
            ConfigError::BadFloat(e) => write!(f, "invalid float: {e}"),
        }
    }
}

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

impl From<ParseIntError> for ConfigError {
    fn from(e: ParseIntError) -> Self {
        ConfigError::BadInt(e)
    }
}

impl From<ParseFloatError> for ConfigError {
    fn from(e: ParseFloatError) -> Self {
        ConfigError::BadFloat(e)
    }
}

struct Config {
    workers: u32,
    ratio: f64,
}

fn get<'a>(text: &'a str, key: &str) -> Result<&'a str, ConfigError> {
    text.lines()
        .filter_map(|ln| ln.split_once('='))
        .find(|(k, _)| k.trim() == key)
        .map(|(_, v)| v.trim())
        .ok_or_else(|| ConfigError::MissingKey(key.to_string()))
}

fn parse_config(text: &str) -> Result<Config, ConfigError> {
    let workers = get(text, "workers")?.parse()?;
    let ratio = get(text, "ratio")?.parse()?;
    Ok(Config { workers, ratio })
}

fn main() {
    let inputs = [
        "workers = 4\nratio = 0.75",
        "workers = four\nratio = 0.75",
        "workers = 4\nratio = high",
        "workers = 4",
    ];

    for text in inputs {
        match parse_config(text) {
            Ok(c) => println!("workers {}, ratio {}", c.workers, c.ratio),
            Err(e) => println!("error: {e}"),
        }
    }
}

We parse a simple configuration in the key = value format.

enum ConfigError {
    MissingKey(String),
    BadInt(ParseIntError),
    BadFloat(ParseFloatError),
}

The error type wraps the errors of the standard library functions it uses.

impl From<ParseIntError> for ConfigError {
    fn from(e: ParseIntError) -> Self {
        ConfigError::BadInt(e)
    }
}

This implementation tells Rust how to turn a ParseIntError into a ConfigError.

let workers = get(text, "workers")?.parse()?;
let ratio = get(text, "ratio")?.parse()?;

The get function returns a ConfigError directly. The parse calls return standard errors, which the ? operator converts with our From implementations. The target types of parse are inferred from the fields of Config.

$ cargo run -q
workers 4, ratio 0.75
error: invalid integer: invalid digit found in string
error: invalid float: invalid float literal
error: missing key 'ratio'

Mapping errors

The map_err method converts an error into another type without a From implementation. It is handy when the conversion needs extra context.

main.rs
#[derive(Debug)]
struct AppError {
    msg: String,
}

fn parse_age(s: &str) -> Result<u8, AppError> {
    let age: u8 = s
        .trim()
        .parse()
        .map_err(|e| AppError { msg: format!("'{s}' is not a valid age: {e}") })?;

    if age > 150 {
        return Err(AppError { msg: format!("{age} is not realistic") });
    }

    Ok(age)
}

fn main() {
    for s in ["34", "-3", "200", "151"] {
        match parse_age(s) {
            Ok(a) => println!("age: {a}"),
            Err(e) => println!("{}", e.msg),
        }
    }
}

We parse an age and add the input to the error message.

let age: u8 = s
    .trim()
    .parse()
    .map_err(|e| AppError { msg: format!("'{s}' is not a valid age: {e}") })?;

The closure receives the original ParseIntError and returns an AppError. Then ? propagates it. The input "200" fits into a u8 and is rejected by our own check.

$ cargo run -q
age: 34
'-3' is not a valid age: invalid digit found in string
200 is not realistic
151 is not realistic

Result combinators

Like Option, Result has methods that transform the value or the error without explicit matching.

main.rs
fn main() {
    let r: Result<i32, String> = Ok(5);
    let doubled = r.clone().map(|n| n * 2);
    println!("{:?}", doubled);

    let bad: Result<i32, String> = Err(String::from("no value"));
    println!("{:?}", bad.clone().map(|n| n * 2));
    println!("{:?}", bad.map_err(|e| e.len()));

    let checked = "8"
        .parse::<i32>()
        .map_err(|e| e.to_string())
        .and_then(|n| if n % 2 == 0 { Ok(n / 2) } else { Err(format!("{n} is odd")) });
    println!("{:?}", checked);

    let fallback = "x".parse::<i32>().or_else(|_| "10".parse::<i32>());
    println!("{:?}", fallback);

    println!("{}", r.is_ok_and(|n| n > 3));
}

We chain several operations on Result values.

let doubled = r.clone().map(|n| n * 2);

map transforms the Ok value and leaves an Err untouched; map_err does the opposite. The methods consume the Result, so we clone it to use it again later.

.and_then(|n| if n % 2 == 0 { Ok(n / 2) } else { Err(format!("{n} is odd")) });

and_then chains an operation that can itself fail. The error types must match, which is why we first convert the ParseIntError to a String.

let fallback = "x".parse::<i32>().or_else(|_| "10".parse::<i32>());

or_else tries an alternative when the first operation fails. is_ok_and tests the value with a predicate.

$ cargo run -q
Ok(10)
Err("no value")
Err(8)
Ok(4)
Ok(10)
true

Errors in iterators

When processing a collection, each element can produce an error. Iterators offer several ways to deal with that.

main.rs
use std::num::ParseIntError;

fn main() {
    let good = ["1", "2", "3"];
    let bad = ["1", "two", "3", "four"];

    let a: Result<Vec<i32>, ParseIntError> = good.iter().map(|s| s.parse()).collect();
    println!("{:?}", a);

    let b: Result<Vec<i32>, ParseIntError> = bad.iter().map(|s| s.parse()).collect();
    println!("{:?}", b);

    let (oks, errs): (Vec<_>, Vec<_>) = bad
        .iter()
        .map(|s| s.parse::<i32>())
        .partition(|r| r.is_ok());
    let nums: Vec<i32> = oks.into_iter().map(Result::unwrap).collect();
    println!("parsed {:?}, {} errors", nums, errs.len());

    let valid: Vec<i32> = bad.iter().filter_map(|s| s.parse().ok()).collect();
    println!("{:?}", valid);

    let sum: Result<i32, _> = good.iter().map(|s| s.parse::<i32>()).sum();
    println!("{:?}", sum);
}

We parse two lists of strings.

let b: Result<Vec<i32>, ParseIntError> = bad.iter().map(|s| s.parse()).collect();

Collecting an iterator of Result values into Result<Vec<_>, _> gives Ok with all values, or the first error. The iteration stops at the first error.

let (oks, errs): (Vec<_>, Vec<_>) = bad
    .iter()
    .map(|s| s.parse::<i32>())
    .partition(|r| r.is_ok());

To keep processing after errors, partition splits the results into successes and failures.

let valid: Vec<i32> = bad.iter().filter_map(|s| s.parse().ok()).collect();

filter_map with ok silently skips the invalid elements. sum and product also accept Result items and stop at the first error.

$ cargo run -q
Ok([1, 2, 3])
Err(ParseIntError { kind: InvalidDigit })
parsed [1, 3], 2 errors
[1, 3]
Ok(6)

Error sources

An error can wrap a lower-level error that caused it. The source method of the Error trait exposes the cause, so that a chain of errors can be reported.

main.rs
use std::error::Error;
use std::fmt;
use std::num::ParseIntError;

#[derive(Debug)]
struct LineError {
    line: usize,
    source: ParseIntError,
}

impl fmt::Display for LineError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "bad value on line {}", self.line)
    }
}

impl Error for LineError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        Some(&self.source)
    }
}

fn parse_lines(text: &str) -> Result<Vec<i32>, LineError> {
    text.lines()
        .enumerate()
        .map(|(i, ln)| ln.parse().map_err(|e| LineError { line: i + 1, source: e }))
        .collect()
}

fn report(e: &dyn Error) {
    println!("error: {e}");
    let mut cause = e.source();
    while let Some(c) = cause {
        println!("  caused by: {c}");
        cause = c.source();
    }
}

fn main() {
    match parse_lines("4\n8\n15") {
        Ok(v) => println!("{:?}", v),
        Err(e) => report(&e),
    }

    match parse_lines("4\n8\n1x5\n16") {
        Ok(v) => println!("{:?}", v),
        Err(e) => report(&e),
    }
}

The LineError type adds the line number to a parse error.

impl Error for LineError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        Some(&self.source)
    }
}

We override the default source method, which returns None, to return the wrapped ParseIntError. The Display text of LineError describes only its own level; the cause is reported separately.

let mut cause = e.source();
while let Some(c) = cause {
    println!("  caused by: {c}");
    cause = c.source();
}

The report function walks the chain of causes. Popular crates make this pattern shorter: thiserror derives the Display, Error, and From implementations for custom error types, and anyhow provides a convenient error type for applications with context messages.

$ cargo run -q
[4, 8, 15]
error: bad value on line 3
  caused by: invalid digit found in string

Source

The Rust Programming Language: Error Handling

Module std::result

Trait std::error::Error

In this article we have handled errors in Rust. The Option type is covered in Rust Option type.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Rust tutorials.