Error Handling in Rust
Rust groups errors into two categories: recoverable errors (like file not found) and unrecoverable errors (like accessing an array out of bounds). Instead of exceptions, Rust uses Result<T, E> for recoverable errors and panic! for unrecoverable ones.
Unrecoverable Errors with panic!
When something goes terribly wrong and there's no way to recover:
fn main() {
// This will panic and stop the program
panic!("crash and burn!");
}
There is no Run button on that one, and the reason is the point: a panic tears the thread down rather than returning from it. Run it and stderr gets thread 'main' (2030249) panicked at src/main.rs:3:5: — the number in parentheses is the operating system's thread id, so yours will differ and it changes every run — then crash and burn! on the next line, followed by the note: run with RUST_BACKTRACE=1 environment variable to display a backtrace hint. By default Rust then unwinds: it walks back up the stack running each destructor along the way, and once it reaches the top of main the process exits with code 101. (You can opt into panic = "abort" in a release profile to skip the unwinding and stop immediately, but that is a build setting, not the default.) Either way there is no return value and nothing for the caller to inspect — that is exactly what makes it unrecoverable.
Common situations that cause panics:
fn main() {
let v = vec![1, 2, 3];
// This will panic: index out of bounds
// v[99]; // Uncommenting this will crash
println!("This won't print if we panic above");
}
The Result Enum
For recoverable errors, Rust uses the Result type:
enum Result<T, E> {
Ok(T), // Success, contains the value
Err(E), // Error, contains error info
}
Before you look for a Run button. That block is a declaration, not a program — it shows you the shape of
Resultas the standard library defines it, and there is nothing in it to execute. The sandbox behind the Run button compiles a whole crate withrustc --edition 2021, so a file containing only anenumfails withE0601: main function not found in crate. Nothing is wrong with the code; it simply has no entry point. You do not need to declare this type yourself either —Resultis in the prelude, already generic over your success typeTand your error typeE. The runnable examples start in the next section, whereResultvalues actually come back from a call.
Basic Usage
use std::fs::File;
fn main() {
let file_result = File::open("hello.txt");
let file = match file_result {
Ok(f) => f,
Err(e) => {
println!("Failed to open file: {}", e);
return;
}
};
println!("File opened successfully!");
}
Handling Different Error Types
use std::fs::File;
use std::io::ErrorKind;
fn main() {
let file = File::open("hello.txt");
let file = match file {
Ok(f) => f,
Err(error) => match error.kind() {
ErrorKind::NotFound => {
println!("File not found, creating it...");
match File::create("hello.txt") {
Ok(fc) => fc,
Err(e) => panic!("Couldn't create file: {:?}", e),
}
}
other_error => {
panic!("Problem opening file: {:?}", other_error);
}
},
};
}
Shortcuts: unwrap and expect
For prototyping or when you're sure an operation will succeed:
use std::fs::File;
fn main() {
// Make the operation genuinely certain to succeed before we shortcut it.
File::create("hello.txt").expect("Failed to create hello.txt");
// expect: panics with YOUR message if Err. Here it succeeds, so it just
// hands back the File.
let file = File::open("hello.txt")
.expect("Failed to open hello.txt");
println!("opened: {:?}", file.metadata().unwrap().is_file());
// unwrap: same thing with a generic panic message instead of your own.
let _again = File::open("hello.txt").unwrap();
println!("unwrap worked too");
// Uncomment to see the failure side: this file does not exist, so expect
// panics with "Failed to open missing.txt: Os { code: 2, ... }".
// let _missing = File::open("missing.txt").expect("Failed to open missing.txt");
}
Propagating Errors
Often you want to return errors to the caller:
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let file = File::open("username.txt");
let mut file = match file {
Ok(f) => f,
Err(e) => return Err(e),
};
let mut username = String::new();
match file.read_to_string(&mut username) {
Ok(_) => Ok(username),
Err(e) => Err(e),
}
}
fn main() {
match read_username_from_file() {
Ok(name) => println!("Username: {}", name),
Err(e) => println!("Error reading username: {}", e),
}
}
The ? Operator
The ? operator provides a concise way to propagate errors:
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let mut file = File::open("username.txt")?;
let mut username = String::new();
file.read_to_string(&mut username)?;
Ok(username)
}
// Even more concise with chaining:
fn read_username_short() -> Result<String, io::Error> {
let mut username = String::new();
File::open("username.txt")?.read_to_string(&mut username)?;
Ok(username)
}
fn main() {
match read_username_from_file() {
Ok(name) => println!("Username: {}", name),
Err(e) => println!("Error: {}", e),
}
}
Both of those functions get away with a bare ? for one reason: every operation they call fails with io::Error, which is exactly what the function returns. The moment the error types differ, ? needs something extra — and what it needs is worth seeing explicitly. The program below returns its own ConfigError, but the ? sits on a parse() that produces a ParseIntError. There is a println! inside the From impl so you can see precisely when the conversion happens.
Predict
parse_port returns Result<i64, ConfigError>, but the ? is applied to parse(), which fails with ParseIntError — a completely different type. The From impl prints a line when it runs. What are the four printed lines, in order?
use std::fmt;
use std::num::ParseIntError;
#[derive(Debug)]
enum ConfigError {
NotANumber(String),
OutOfRange(i64),
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ConfigError::NotANumber(msg) => write!(f, "not a number ({})", msg),
ConfigError::OutOfRange(n) => write!(f, "{} is outside 1..=65535", n),
}
}
}
// This impl is the ONLY thing that lets ? cross from one error type to another.
impl From<ParseIntError> for ConfigError {
fn from(e: ParseIntError) -> Self {
println!(" [From ran]");
ConfigError::NotANumber(e.to_string())
}
}
fn parse_port(raw: &str) -> Result<i64, ConfigError> {
// parse() produces ParseIntError, but this function returns ConfigError.
let n: i64 = raw.trim().parse()?;
if !(1..=65535).contains(&n) {
return Err(ConfigError::OutOfRange(n));
}
Ok(n)
}
fn main() {
match parse_port("8080") {
Ok(port) => println!("a: port {}", port),
Err(e) => println!("a: error: {}", e),
}
match parse_port("http") {
Ok(port) => println!("b: port {}", port),
Err(e) => println!("b: error: {}", e),
}
match parse_port("99999") {
Ok(port) => println!("c: port {}", port),
Err(e) => println!("c: error: {}", e),
}
}The lines are a: port 8080, then [From ran], then b: error: not a number (invalid digit found in string), then c: error: 99999 is outside 1..=65535. ? is not merely an early return — it is an early return with a conversion. It desugars to roughly match expr { Ok(v) => v, Err(e) => return Err(From::from(e)) }, so the function's error type only has to implement From of whatever error the expression produced. That is why one custom error enum plus a few From impls lets every fallible step in a function use a bare ?. Two details worth keeping: the conversion runs only on the failing path (call a never touches From), and an Err you construct yourself at the right type — like the OutOfRange case — bypasses it entirely.
Using ? with Option
The ? operator also works with Option:
fn last_char_of_first_line(text: &str) -> Option<char> {
text.lines().next()?.chars().last()
}
fn main() {
let text = "Hello\nWorld";
match last_char_of_first_line(text) {
Some(c) => println!("Last char: {}", c),
None => println!("No character found"),
}
}
Creating Custom Errors
You can define your own error types:
use std::fmt;
#[derive(Debug)]
struct ValidationError {
message: String,
}
impl fmt::Display for ValidationError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "Validation error: {}", self.message)
}
}
fn validate_age(age: i32) -> Result<(), ValidationError> {
if age < 0 {
Err(ValidationError {
message: String::from("Age cannot be negative"),
})
} else if age > 150 {
Err(ValidationError {
message: String::from("Age seems unrealistic"),
})
} else {
Ok(())
}
}
fn main() {
match validate_age(25) {
Ok(()) => println!("Age is valid"),
Err(e) => println!("{}", e),
}
match validate_age(-5) {
Ok(()) => println!("Age is valid"),
Err(e) => println!("{}", e),
}
}
When to Use panic! vs Result
Use panic! when:
- The error is unrecoverable (bug in code, corrupted data)
- In examples, prototypes, or tests
- When you're absolutely sure the code won't fail
Use Result when:
- The error is expected and recoverable
- The caller should decide how to handle it
- In library code (let users decide)
That table has a third column nobody writes down, and it is the one that causes production incidents: silently doing neither. Before meeting an example of it, recall the tool that makes it possible.
Recall
Without scrolling up: in Option and Result you met .ok(), which converts a Result into an Option. Recall precisely what it does with the error, and say what that means for a function whose job is to report failures.
.ok() maps Ok(v) to Some(v) and Err(e) to None, and the error value is gone — None carries no payload, so there is nowhere for it to live. That makes .ok() right where a fallback is coming and the reason genuinely does not matter, and wrong anywhere the function's contract is to explain what failed. Its mirror image, ok_or/ok_or_else, converts the other way by supplying an error the Option never had.
Which brings us to the failure mode that is worse than a panic: code that neither panics nor reports. The next program compiles without a warning, returns Ok for both inputs, and looks entirely correct in review. Work out what happened to the bad row before you change anything:
Debug
parse_rows must abort on the first bad row and report it — a partial order is worse than no order. It compiles cleanly and both calls return Ok, but one of them is hiding a rejected row. Say which line discards the failure, and why nothing warns you, then fix it.
#[derive(Debug, PartialEq)]
enum RowError {
BadQuantity(String),
}
/// Parse "name, quantity" rows into an order.
/// The FIRST bad row must abort the parse and be reported to the caller —
/// a partial order that silently drops a line is worse than no order at all.
fn parse_rows(rows: &[&str]) -> Result<Vec<(String, u32)>, RowError> {
let parsed: Vec<(String, u32)> = rows
.iter()
.filter_map(|row| {
let (name, qty_raw) = row.split_once(',')?;
let qty = qty_raw.trim().parse::<u32>().ok()?;
Some((name.trim().to_string(), qty))
})
.collect();
Ok(parsed)
}
fn main() {
let good = ["bolts, 12", "nuts, 4"];
let bad = ["bolts, 12", "washers, many", "nuts, 4"];
println!("good -> {:?}", parse_rows(&good));
println!("bad -> {:?}", parse_rows(&bad));
assert_eq!(
parse_rows(&good),
Ok(vec![("bolts".to_string(), 12), ("nuts".to_string(), 4)]),
"a clean input should parse both rows"
);
assert_eq!(
parse_rows(&bad),
Err(RowError::BadQuantity("many".to_string())),
"a bad quantity must abort the parse and be reported, not be dropped"
);
println!("All checks passed.");
}Expected output: good -> Ok([("bolts", 12), ("nuts", 4)])
bad -> Err(BadQuantity("many"))
All checks passed.
Two lines conspire, and neither one warns. .ok() throws the parse error away, and the ? after it returns early from the closure, not from parse_rows — and filter_map treats a closure returning None as "skip this element", which is exactly what it is designed for. So the bad row vanishes and the function reports Ok with a shorter Vec; the only evidence is that one list is shorter than its input. The fix is structural: a skipping adapter cannot express "abort", so replace the chain with a for loop that uses map_err(...)? on the parse, which propagates out of parse_rows itself. Keep the general form of this: filter_map and .ok() are both "skip the failure" tools, and using them inside a function contracted to report failures yields a plausible, silently truncated answer instead of an error.
Practice Exercise
Reading about ? is not the same as designing an error type it can flow through. This is a build task: a small program that reports its own pass/fail. Four functions are stubbed out — three small fallible steps and one that chains them with ?. Run the starter as-is and it fails immediately, naming the first check that did not pass. Implement each until every check passes and it prints All checks passed.
The RecordError enum and its Display impl are written for you, because the exercise is the plumbing, not the formatting: each of the three steps has to produce a RecordError (ok_or for the missing case, map_err for the parse case, a plain Err for the range case) so that parse_record can chain all three with bare ? and let the first failure fall straight out to the caller.
Build
Finish the build. Four functions are stubbed out and the checks below them fail until each one behaves. Run it as-is to see which check fails first, decide what that function is missing, then implement all four until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — but note TODO 4 is what the checks actually call, so nothing passes until the chain is wired up too.
// A record parser with one error type covering three distinct failures.
use std::fmt;
#[derive(Debug, PartialEq)]
enum RecordError {
MissingField(&'static str),
BadNumber(String),
OutOfRange(u32),
}
impl fmt::Display for RecordError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
RecordError::MissingField(name) => write!(f, "missing field: {}", name),
RecordError::BadNumber(raw) => write!(f, "not a number: '{}'", raw),
RecordError::OutOfRange(n) => write!(f, "{} is outside 0..=100", n),
}
}
}
// TODO 1: split record once on ':' into (name, raw_score).
// No colon means the score field is missing:
// Err(RecordError::MissingField("score")).
// Hint: split_once(':') returns an Option<(&str, &str)>; ok_or turns an
// Option into a Result by supplying the error it never had.
fn split_record(record: &str) -> Result<(&str, &str), RecordError> {
let _ = record;
Ok(("", ""))
}
// TODO 2: trim raw and parse it as a u32, converting the parse failure
// into RecordError::BadNumber(<the trimmed text>).
// parse::<u32>() gives a Result whose error is a ParseIntError, while this
// function returns a Result<u32, RecordError> —
// map_err is what bridges the two error types.
fn parse_score(raw: &str) -> Result<u32, RecordError> {
let _ = raw;
Ok(0)
}
// TODO 3: accept a score of 0..=100 and reject anything higher with
// RecordError::OutOfRange(score).
fn check_range(score: u32) -> Result<u32, RecordError> {
Ok(score)
}
// TODO 4: chain the three above with ? so the FIRST failure is returned
// to the caller unchanged, and a success yields (trimmed name, score).
// Every ? here returns a RecordError, which is already this function's
// error type — so no conversion is needed at any of the three steps.
fn parse_record(record: &str) -> Result<(String, u32), RecordError> {
let _ = split_record(record);
let _ = parse_score(record);
let _ = check_range(0);
Ok((String::new(), 0))
}
// --- Build checks: these must all pass. Do not edit below this line. ---
fn main() {
assert_eq!(parse_record("ana: 91"), Ok(("ana".to_string(), 91)), "a well-formed record should parse");
assert_eq!(
parse_record("ana"),
Err(RecordError::MissingField("score")),
"a record with no colon is a MissingField error"
);
assert_eq!(
parse_record("ana: high"),
Err(RecordError::BadNumber("high".to_string())),
"a non-numeric score is a BadNumber error naming the offending text"
);
assert_eq!(
parse_record("ana: 140"),
Err(RecordError::OutOfRange(140)),
"a score above 100 is OutOfRange"
);
println!("All checks passed.");
println!("ok: {:?}", parse_record("ana: 91"));
println!("missing: {}", parse_record("ana").unwrap_err());
println!("bad: {}", parse_record("ana: high").unwrap_err());
println!("range: {}", parse_record("ana: 140").unwrap_err());
}Expected output: All checks passed.
ok: Ok(("ana", 91))
missing: missing field: score
bad: not a number: 'high'
range: 140 is outside 0..=100
Once it passes, try two variations and predict each before running:
- Delete the
map_errand let?do the conversion. Inparse_score, replace the body withlet n = trimmed.parse::<u32>()?; Ok(n). Predict what happens before running. It does not compile:error[E0277]: ? couldn't convert the error to RecordError, with the note "RecordErrorneeds to implementFrom<ParseIntError>".?will convert error types for you, but only along aFromimpl you have written — and this program has none. Addimpl From<ParseIntError> for RecordError(plususe std::num::ParseIntError;) and the bare?compiles, at the cost of losing the offending text unless the impl keeps it. - Swap one
?for anunwrap. Inparse_record, change the first line tolet (name, raw) = split_record(record).unwrap();. Predict which check breaks and how before running. It compiles, and theMissingFieldcheck does not fail — it crashes:thread 'main' panicked at ... called Result::unwrap() on an Err value: MissingField("score"). The program stops at the first bad record rather than reporting it, which is precisely thepanic!-versus-Resultdistinction from earlier in this lesson, applied by accident.
Key Takeaways
- Rust has no exceptions - use
Resultandpanic! panic!for unrecoverable errors that crash the programResult<T, E>for recoverable errors withOk(T)orErr(E)- Use
unwrap()andexpect()for quick prototyping - The
?operator propagates errors concisely - Prefer
Resultin library code to give callers control - Create custom error types for domain-specific errors
Proper error handling makes your Rust programs robust and reliable!
Next Steps
Now that you understand error handling, you're ready to learn about collections — the vectors, hash maps, and sets Rust uses to store and manage groups of values. Many of their operations return Option, so the handling patterns you just learned will feel right at home.
Next lesson
Collections
Learn to use Rust's standard collections including Vec, HashMap, and HashSet with iterators
25 min