Error Handling
Errors as values: Option for a missing value, Result for an operation that can fail, the ? operator to pass errors up, panics for bugs, and when unwrap and expect are acceptable.
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
unwraplater 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")). Preferexpectwith a reason over a bareunwrap.
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_crcreturnsOption<u16>, so code that compares the sent CRC with the computed one cannot compile until it says what happens onNone. C'sstrtoulreturns 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 |