Errors are values

Rust separates two kinds of failure. A recoverable error is something a correct program must expect: a meter sends a reading that is not a number, a file is missing, a server does not answer. The function returns it as an ordinary value, and the caller decides what to do. An unrecoverable error is a bug: an index past the end of a list, a state the code assumed could never happen. Then the program panics and stops.

For the recoverable kind there are no exceptions that fly up the call stack invisibly. The possibility of failure is part of the function's type, so you can see it in the signature, and the compiler checks that every caller handles it.

Panics: when the program has a bug

A panic stops the current thread with a message saying what went wrong and where. Indexing past the end of an array is the classic case. C would read whatever memory lies behind the array; Rust checks the index and panics:

fn main() {
    let readings = [350, 412, 120];
    let day = readings.len(); // one past the last valid index
    println!("first: {}", readings[0]);
    println!("day {day}: {}", readings[day]);
}
first: 350
thread 'main' (48213) panicked at example.rs:5:31:
index out of bounds: the len is 3 but the index is 3

In a terminal the complete message on standard error reads:

first: 350

thread 'main' (3171218) panicked at example.rs:5:31:
index out of bounds: the len is 3 but the index is 3
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

The header names the thread ('main') and, in parentheses, a numeric thread ID, which differs on every run. Then come the file, line and column of the panic, and the message on its own line. The program exits with status 101, so a script or service manager can see that it failed (see exit statuses below). Panics also happen on integer overflow in a debug build and on division by zero (see Variables, Types and Mutability). You can raise one yourself with panic!("meter table is corrupt"), and todo!() and unreachable!() are panics with a ready-made message.

Setting the environment variable RUST_BACKTRACE=1 adds the chain of function calls that led to the panic. That is what a developer asks for when you report a crash:

$ RUST_BACKTRACE=1 ./example
first: 350

thread 'main' (3171219) panicked at example.rs:5:31:
index out of bounds: the len is 3 but the index is 3
stack backtrace:
   0: __rustc::rust_begin_unwind
   1: core::panicking::panic_fmt
   2: core::panicking::panic_bounds_check
   3: example::main
   4: <fn() as core::ops::function::FnOnce<()>>::call_once
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

Read it from the top down until you reach your own code: frames 0 to 2 are the standard library raising the panic, frame 3 is the function where it happened.

Option: a value that may be missing

The Option type, Option<T>, is Rust's replacement for null. It is an enum with two variants, Some(value) and None (see Option is an enum). Functions that may have nothing to return use it: readings.get(5) returns None where readings[5] would panic, readings.last() returns None for an empty list.

fn main() {
    let readings = vec![350, 412, 120];

    match readings.get(1) {
        Some(kwh) => println!("day 2: {kwh} kWh"),
        None => println!("day 2: no reading"),
    }

    let day_6 = readings.get(5);
    println!("day 6: {day_6:?}");
    println!("day 6 or zero: {}", readings.get(5).copied().unwrap_or(0));

    if let Some(last) = readings.last() {
        println!("last: {last} kWh");
    }
}
day 2: 412 kWh
day 6: None
day 6 or zero: 0
last: 120 kWh

match handles both cases, if let handles only the one you care about, and unwrap_or(0) supplies a default. (.copied() turns the Option<&u32> that get returns, a reference into the vector, into an Option<u32>.) What you cannot do is forget that the value might be missing. An Option<u32> is not a u32, and the compiler says so:

fn main() {
    let readings = vec![350, 412, 120];
    let latest: u32 = readings.get(5);
    println!("{latest}");
}
example.rs:3:23: error[E0308]: mismatched types: expected `u32`, found `Option<&{integer}>`

This is the whole point. In Java, C# or TypeScript a reference can be null unless the compiler is told otherwise (compare Java's Optional and TypeScript's strict null checks); in C a NULL pointer looks exactly like a valid one until it is dereferenced. In Rust "maybe missing" is a different type from "present", and converting one into the other is always visible in the code.

Useful methods on Option:

Method Does
unwrap_or(default), unwrap_or_default() the value, or a fallback (zero, empty string)
map(|v| …) changes the value inside Some, leaves None alone
ok_or(error) turns Option into Result: None becomes Err(error)
is_some(), is_none() just the question, without the value
filter(|v| …) keeps the value only if a condition holds

Result: an operation that can fail

The Result type, Result<T, E>, is an enum with Ok(value) and Err(error). Where Option only says "nothing", Result says why. Parsing text into a number is the everyday example: "350".parse::<u32>() returns Result<u32, ParseIntError>.

fn main() {
    for input in ["350", "+350", "35O", "", "99999999999"] {
        match input.parse::<u32>() {
            Ok(kwh) => println!("{input:?}: {kwh} kWh"),
            Err(e) => println!("{input:?}: {e}"),
        }
    }
}
"350": 350 kWh
"+350": 350 kWh
"35O": invalid digit found in string
"": cannot parse integer from empty string
"99999999999": number too large to fit in target type

Each error prints a clear reason with {} (its Display form). Note two details that matter when you parse input from devices: a leading + is accepted, and a value that does not fit in the target type is an error, not a silently wrong number. Leading spaces are not skipped: " 350" is an invalid digit, so trim the text first.

A Result that you simply drop is almost always a mistake, so the standard library marks the type as "must use". The compiler warns, and with -D warnings (as on this site and in many build pipelines) the warning stops the build:

fn main() {
    let input = "35O";
    input.parse::<u32>();
    println!("done");
}
example.rs:3:5: error: unused `Result` that must be used

If you really want to ignore it, say so with let _ = input.parse::<u32>();; the compiler's help line suggests exactly that. A reviewer then sees a deliberate choice instead of an oversight.

The ? operator: passing errors up

Most functions cannot fix an error themselves; they hand it to their caller. That is error propagation, and writing a match for every step would bury the logic. The question mark operator does it in one character: expr? gives the value inside Ok, or returns the Err from the current function at once.

use std::num::ParseIntError;

// Adds up readings; stops at the first one that is not a number.
fn total_kwh(lines: &[&str]) -> Result<u32, ParseIntError> {
    let mut total = 0;
    for line in lines {
        let kwh: u32 = line.trim().parse()?;
        total += kwh;
    }
    Ok(total)
}

fn main() {
    println!("{:?}", total_kwh(&["350", "412", "120"]));
    println!("{:?}", total_kwh(&["350", "41 2", "120"]));
}
Ok(882)
Err(ParseIntError { kind: InvalidDigit })

The ? works the same way on Option in a function that returns Option: a None returns None. This makes step-by-step parsing short. In this example a power line of a P1 telegram, 1-0:1.7.0(01.234*kW), becomes a number of watts, and any line that does not have that exact shape gives None:

fn power_watts(line: &str) -> Option<u32> {
    let value = line.strip_prefix("1-0:1.7.0(")?.strip_suffix("*kW)")?;
    let (kw, fraction) = value.split_once('.')?;
    if fraction.len() != 3 {
        return None;
    }
    Some(kw.parse::<u32>().ok()? * 1000 + fraction.parse::<u32>().ok()?)
}

fn main() {
    for line in ["1-0:1.7.0(01.234*kW)", "1-0:1.7.0(01.2*kW)", "1-0:2.7.0(00.000*kW)"] {
        println!("{line}: {:?}", power_watts(line));
    }
}
1-0:1.7.0(01.234*kW): Some(1234)
1-0:1.7.0(01.2*kW): None
1-0:2.7.0(00.000*kW): None

.ok() converts a Result into an Option (dropping the reason), so that ? can be used on the parse inside a function that returns Option. The two kinds do not mix by themselves: ? on a Result needs a function that returns Result.

? in main

A plain fn main() returns nothing, so it has nowhere to send an error. Using ? there is refused, and the compiler writes the fix for you:

error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
 --> example.rs:2:42
  |
1 | fn main() {
  | --------- this function should return `Result` or `Option` to accept `?`
2 |     let total: u32 = "350".parse::<u32>()? + 12;
  |                                          ^ cannot use the `?` operator in a function that returns `()`
  |
help: consider adding return type
  |
1 ~ fn main() -> Result<(), Box<dyn std::error::Error>> {
2 |     let total: u32 = "350".parse::<u32>()? + 12;
3 |     println!("{total}");
4 +     Ok(())
  |

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0277`.

With that signature, main can use ?. Box<dyn Error> means "any error type"; how that works is on Traits and Generics. When main returns an Err, Rust prints Error: and the error's Debug form to standard error and exits with status 1:

use std::error::Error;

fn main() -> Result<(), Box<dyn Error>> {
    let lines = ["350", "412", "35O"];
    let mut total: u32 = 0;
    for line in lines {
        total += line.parse::<u32>()?;
        println!("running total: {total} kWh");
    }
    println!("total: {total} kWh");
    Ok(())
}
running total: 350 kWh
running total: 762 kWh
Error: ParseIntError { kind: InvalidDigit }

That is fine for a small tool. For a tool that other people run, print a message meant for people instead (match on the result in main, print the Display form with eprintln!, and exit with std::process::ExitCode::FAILURE), because ParseIntError { kind: InvalidDigit } does not say which line was wrong.

Your own error type

In a real program a step can fail for different reasons. An enum with one variant per reason describes them exactly, and two small trait implementations make it a proper error: Display for the message and From so that ? can convert a lower-level error into yours automatically. In this example the reading tool from Structs, Enums and Methods now says why it rejects a line:

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

#[derive(Debug)]
enum ReadingError {
    MissingField(&'static str),
    BadNumber(ParseIntError),
    UnknownTariff(String),
}

impl fmt::Display for ReadingError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            ReadingError::MissingField(name) => write!(f, "missing field: {name}"),
            ReadingError::BadNumber(e) => write!(f, "kWh is not a whole number ({e})"),
            ReadingError::UnknownTariff(t) => write!(f, "unknown tariff {t:?}"),
        }
    }
}

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

// Lets ? turn a ParseIntError into a ReadingError automatically.
impl From<ParseIntError> for ReadingError {
    fn from(e: ParseIntError) -> Self {
        ReadingError::BadNumber(e)
    }
}

struct Reading {
    meter: String,
    kwh: u32,
}

fn parse_line(line: &str) -> Result<Reading, ReadingError> {
    let mut parts = line.split(';');
    let meter = parts
        .next()
        .filter(|m| !m.is_empty())
        .ok_or(ReadingError::MissingField("meter"))?;
    let kwh = parts.next().ok_or(ReadingError::MissingField("kwh"))?.parse()?;
    match parts.next() {
        Some("fixed" | "day_night" | "dynamic" | "variable") => {}
        Some(other) => return Err(ReadingError::UnknownTariff(other.to_string())),
        None => return Err(ReadingError::MissingField("tariff")),
    }
    Ok(Reading { meter: meter.to_string(), kwh })
}

fn main() {
    let lines = [
        "M-30004;350;fixed",
        "M-30007;41.2;day_night",
        "M-30012;120",
        "M-30009;95;weekend",
        ";80;fixed",
    ];
    for line in lines {
        match parse_line(line) {
            Ok(r) => println!("ok       {} {} kWh", r.meter, r.kwh),
            Err(e) => println!("rejected {line:?}: {e}"),
        }
    }
}
ok       M-30004 350 kWh
rejected "M-30007;41.2;day_night": kWh is not a whole number (invalid digit found in string)
rejected "M-30012;120": missing field: tariff
rejected "M-30009;95;weekend": unknown tariff "weekend"
rejected ";80;fixed": missing field: meter

Two methods keep chains like this short: ok_or turns a missing part (None) into a specific error, and map_err converts one error into another when no From exists, for example .map_err(|e| format!("line {n}: {e}")). Many projects use two popular crates for the boilerplate: thiserror derives the Error trait, Display and From for an error enum like this one, and anyhow offers one catch-all error type with context messages for applications. Both build on the same standard traits shown here.

unwrap and expect

unwrap() gives the value inside Some or Ok and panics on None or Err. It is the "I am sure this works" shortcut:

fn main() {
    let kwh: u32 = "350".parse().unwrap();
    println!("parsed {kwh}");
    let bad: u32 = "35O".parse().unwrap();
    println!("parsed {bad}");
}
parsed 350
thread 'main' (48213) panicked at example.rs:4:34:
called `Result::unwrap()` on an `Err` value: ParseIntError { kind: InvalidDigit }

expect("…") does the same but puts your own text in the panic message, before the error. That text is the first thing someone reads when the program stops, so phrase it as what you expected to be true:

fn main() {
    let input = "";
    let kwh: u32 = input.parse().expect("meter reading must be a whole number of kWh");
    println!("parsed {kwh}");
}
thread 'main' (48213) panicked at example.rs:3:34:
meter reading must be a whole number of kWh: ParseIntError { kind: Empty }

When is turning an error into a panic acceptable?

  • Tests and examples, where a panic is exactly the failure you want to see.
  • Prototypes, as a marker of "error handling still to do". Searching the code for unwrap later finds every place.
  • Things that cannot fail unless the program itself is wrong: parsing a constant you wrote into the code, or reading standard input in a small tool (line.expect("stdin is readable")). Prefer expect with a reason over a bare unwrap.

Never for data from outside: files, user input, network replies, meter telegrams. One malformed line should be rejected or logged, not stop a service that handles 400,000 meters. Clippy, Rust's linter, has optional lints (unwrap_used, expect_used) that a team can switch on to forbid both; with allow-unwrap-in-tests and allow-expect-in-tests in clippy.toml, tests may still use them (see Testing, Docs and Tooling).

Exit statuses: what operations sees

For someone who runs Rust tools from scripts, cron jobs or a service manager, the exit status tells the three outcomes apart. In this example a small tool parses its first argument and has a bug on one path:

cat > report.rs <<'EOF'
use std::error::Error;

fn main() -> Result<(), Box<dyn Error>> {
    let input = std::env::args().nth(1).unwrap_or_default();
    let readings: Vec<u32> = Vec::new();
    if input == "first" {
        println!("first reading: {}", readings[0]); // a bug: the list is empty
    }
    let kwh: u32 = input.parse()?;
    println!("{kwh} kWh");
    Ok(())
}
EOF
rustc --edition 2024 report.rs
./report 350; echo "exit status: $?"
./report 35O; echo "exit status: $?"
./report first 2>/dev/null; echo "exit status: $?"
350 kWh
exit status: 0
Error: ParseIntError { kind: InvalidDigit }
exit status: 1
exit status: 101
Exit status Means What to do
0 success nothing
1 (or another code the tool chooses) the tool handled an error: bad input, missing file read the message on standard error; usually the input or the environment needs fixing
101 a panic: the tool hit a bug or an unhandled case report it to the developers with the message and, if possible, a RUST_BACKTRACE=1 run

cargo run passes the program's exit status on (1 stays 1, a panic is 101), and cargo test exits with 101 when a test fails, so a build pipeline sees the failure without extra work.

Example: the BUG-1060 parser in Rust

On 24 September 2026 one Kestrel Home pilot dongle rejected every telegram, because the meter sent its CRC as three hex digits, B9F, and the parser accepted exactly four (BUG-1060; the story is on Smart Meters and the P1 Port). The dongle's firmware is the outside manufacturer's C code and stays C. In this example, a developer on the platform team writes a Rust prototype of the field parser to compare with the manufacturer's fix:

// The CRC field after '!' in a P1 telegram: one to four hex digits, then CR LF.
fn sent_crc(tail: &str) -> Option<u16> {
    let digits = tail.strip_suffix("\r\n")?;
    if digits.is_empty() || digits.len() > 4 || !digits.bytes().all(|b| b.is_ascii_hexdigit()) {
        return None;
    }
    u16::from_str_radix(digits, 16).ok()
}

fn main() {
    for tail in ["F1F4\r\n", "0B9F\r\n", "B9F\r\n", "+B9F\r\n", " 0xB9F\r\n", "-1\r\n", "F1F4F\r\n", "\r\n"] {
        let shown = tail.trim_end();
        let strict = match sent_crc(tail) {
            Some(crc) => format!("{crc:04X}"),
            None => String::from("rejected"),
        };
        let lenient = match u16::from_str_radix(shown, 16) {
            Ok(crc) => format!("{crc:04X}"),
            Err(e) => format!("error: {e}"),
        };
        println!("{:10} sent_crc: {:9} from_str_radix alone: {}", format!("{shown:?}"), strict, lenient);
    }
}
"F1F4"     sent_crc: F1F4      from_str_radix alone: F1F4
"0B9F"     sent_crc: 0B9F      from_str_radix alone: 0B9F
"B9F"      sent_crc: 0B9F      from_str_radix alone: 0B9F
"+B9F"     sent_crc: rejected  from_str_radix alone: 0B9F
" 0xB9F"   sent_crc: rejected  from_str_radix alone: error: invalid digit found in string
"-1"       sent_crc: rejected  from_str_radix alone: error: invalid digit found in string
"F1F4F"    sent_crc: rejected  from_str_radix alone: error: number too large to fit in target type
""         sent_crc: rejected  from_str_radix alone: error: cannot parse integer from empty string

What the types do for you here, compared with C:

  • The caller must handle the rejected case. sent_crc returns Option<u16>, so code that compares the sent CRC with the computed one cannot compile until it says what happens on None. C's strtoul returns 0 for garbage, and 0 is a valid CRC (see strict input in C).
  • Too many digits is an error, not a bigger number. The result is a u16; "F1F4F" cannot become a five-digit value that some later code truncates.
  • A minus sign is refused instead of wrapping around to a huge unsigned value.

And the honest caveat, which is the most useful line of the table: from_str_radix on its own accepts a leading +, so "+B9F" would pass as 0B9F. The explicit check that every character is a hex digit is still needed. The type system guarantees that you handle the error case; it does not decide for you what valid input looks like. That is still your specification, and your tests:

fn sent_crc(tail: &str) -> Option<u16> {
    let digits = tail.strip_suffix("\r\n")?;
    if digits.is_empty() || digits.len() > 4 || !digits.bytes().all(|b| b.is_ascii_hexdigit()) {
        return None;
    }
    u16::from_str_radix(digits, 16).ok()
}

#[cfg(test)]
mod tests {
    use super::sent_crc;

    #[test]
    fn four_digits_are_accepted() {
        assert_eq!(sent_crc("F1F4\r\n"), Some(0xF1F4));
    }

    #[test]
    fn three_digit_crc_is_accepted() {
        // BUG-1060: a meter sent B9F instead of 0B9F
        assert_eq!(sent_crc("B9F\r\n"), Some(0x0B9F));
    }

    #[test]
    fn plus_sign_is_rejected() {
        assert_eq!(sent_crc("+B9F\r\n"), None);
    }

    #[test]
    fn missing_line_end_is_rejected() {
        assert_eq!(sent_crc("F1F4"), None);
    }
}
running 4 tests
test tests::four_digits_are_accepted ... ok
test tests::missing_line_end_is_rejected ... ok
test tests::plus_sign_is_rejected ... ok
test tests::three_digit_crc_is_accepted ... ok

test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

How tests are organized and run with Cargo is on Testing, Docs and Tooling. A parser of untrusted device data is also a good candidate for fuzzing, which feeds it millions of malformed inputs; in safe Rust code, what a fuzzer finds is a panic or a wrong result rather than memory corruption (code that uses unsafe or calls a C library can still corrupt memory).

Choosing the right tool

Situation Use
A value may be absent, and that is normal (lookup, last element, optional field) Option<T>
An operation can fail and the caller should know why Result<T, E>
You cannot handle the error here ? to pass it up
Several reasons for failure in your own code an error enum with Display and From
A sensible default exists unwrap_or, unwrap_or_default
The situation means the program has a bug a panic: expect("…"), panic!, unreachable!
Tests, quick experiments unwrap() is fine