Updated Aug 6, 2026

08. Error Handling

📋 Jump to Takeaways

🎁 Rust has no exceptions. No try/catch. No null. And somehow it's better at error handling than languages that have all three. Errors are just values, you return them, match on them, and the compiler makes sure you never ignore one by accident.

panic! vs Recoverable Errors

Not every failure deserves a crash. Rust sorts errors into two buckets: unrecoverable ones (bugs) and recoverable ones (the failures you expect and plan for).

// Unrecoverable — program crashes immediately
panic!("something went terribly wrong");

// Recoverable — caller decides what to do
let file = std::fs::read_to_string("config.toml");
// file is Result<String, io::Error>

Use panic! for situations where your program has reached a state that should be impossible: a config value that must exist, an invariant that was violated, a code path that should never execute. Things like index out of bounds already panic automatically at runtime — you don't write those yourself. Use Result for anything that might fail in normal operation: file I/O, network calls, parsing.

This is the same philosophy as Go's panic vs returning error. In both languages, ~95% of errors should be handled by returning them to the caller, not crashing. If you're writing panic! often, something's wrong with the design. Reserve it for situations where continuing would be worse than stopping.

The Result<T, E> Enum

So what is a Result, really? Just an enum with two variants:

enum Result<T, E> {
    Ok(T),   // success, holds the value
    Err(E),  // failure, holds the error
}

Every function that can fail returns a Result. No exceptions flying through your call stack, the error is right there in the return type.

use std::fs;

fn read_config() -> Result<String, std::io::Error> {
    fs::read_to_string("config.toml")
}

Matching on Result

You handle a Result with pattern matching, just like Option.

match fs::read_to_string("config.toml") {
    Ok(contents) => println!("Config: {}", contents),
    Err(e) => println!("Failed to read config: {}", e),
}

What if you just ignore the Result entirely?

fn main() {
    fs::read_to_string("config.toml"); // no match, no variable
}
// warning: unused `Result` that must be used

The compiler warns you. In Go, you can silently _ = someFunc() and the ignored error disappears forever. In Rust, the compiler nags you until you handle it or explicitly opt out with let _ =.

unwrap and expect

Every Result forces you to handle both Ok and Err. But sometimes you don't want to write a full match — you just want the value and are willing to crash if it's not there. That's what unwrap and expect do: give me the Ok value, or panic immediately.

// Panics with a generic message if Err
let contents = fs::read_to_string("config.toml").unwrap();
// thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: NotFound'

// Panics with YOUR message if Err
let contents = fs::read_to_string("config.toml")
    .expect("config.toml must exist in project root");
// thread 'main' panicked at 'config.toml must exist in project root: NotFound'

Both do the exact same thing: extract the Ok value or panic. The only difference is the message you see in the terminal when it panics. The string you pass to expect isn't a condition — it's the error message for your future self reading the logs.

unwrap is fine for quick scripts and prototyping. expect is better when the panic message should explain why the value must exist. In production code, prefer ? to propagate errors instead of crashing.

The ? Operator

Writing a match for every fallible call gets old fast. The ? operator is the shortcut. If the value is Ok, it hands you the inner value. If it's Err, it returns early from the function with that error.

use std::fs;
use std::io;

fn read_username() -> Result<String, io::Error> {
    let contents = fs::read_to_string("username.txt")?;
    Ok(contents.trim().to_string())
}

The ? is shorthand for:

let contents = match fs::read_to_string("username.txt") {
    Ok(s) => s,
    Err(e) => return Err(e),
};

Without ?, you'd need a match for every fallible call. With it, error propagation is a single character. You can chain multiple ? calls and the function returns the first error encountered.

fn setup() -> Result<String, io::Error> {
    let config = fs::read_to_string("config.toml")?;
    let db_url = fs::read_to_string("db_url.txt")?;
    let secret = fs::read_to_string("secret.key")?;
    Ok(format!("{}{}{}", config, db_url, secret))
}

Three potential failures, zero nested matches.

You can also use ? in main() by changing its return type:

fn main() -> Result<(), std::io::Error> {
    let config = fs::read_to_string("config.toml")?;
    println!("{}", config);
    Ok(())
}

Key Takeaways

  • panic! is for bugs; Result<T, E> is for expected failures
  • Result is an enum: Ok(value) or Err(error), no exceptions needed
  • The compiler warns you if you ignore a Result — you can't silently skip errors
  • unwrap/expect panic on error — use only in tests or when failure is impossible
  • ? propagates errors in one character, returns early on Err
  • You can use ? in main() by returning Result<(), Error>

🎁 Next up: you'll learn how Vec<T> grows dynamically without garbage collection, why slices give you zero-cost views into data, and why String is surprisingly complicated — it's not just an array of characters.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue
© 2026 ByteLearn.dev. Free courses for developers. · Privacy