scipio avatar

Learn Rust Series (#6) - Error Handling

scipio

Published: 24 Jul 2026 › Updated: 24 Jul 2026Learn Rust Series (#6) - Error Handling

Learn Rust Series (#6) - Error Handling

Learn Rust Series (#6) - Error Handling

rust-banner.png

What will I learn

  • You will learn why Rust models failure with Result values instead of throwing exceptions, and what that design actually buys you;
  • how the ? operator propagates errors in one character without hiding the error path;
  • the difference between Option (for absence) and Result (for failure), and how to bridge one into the other;
  • when reaching for panic!, unwrap, and expect is the right call, and when returning a Result is;
  • how to build your own custom error type with Display, the standard Error trait, and a From impl so ? converts errors for you automatically.

Requirements

  • A working Rust toolchain (via rustup) from episode 1;
  • A terminal and an editor (VS Code with rust-analyzer is a fine pick);
  • Episodes 1 to 5 read, because today leans hard on match, Option, and the enums we built last time.

Difficulty

  • Beginner

Curriculum (of the Learn Rust Series):

Learn Rust Series (#6) - Error Handling

Welcome back! Last episode we built our own types with structs and enums, and right at the end I sketched a little Outcome enum that was either a success carrying a number or a failure carrying a reason. I told you it was a preview. Well, today the preview becomes the feature film, because that exact shape -- a value that is either "it worked, here is the result" or "it failed, here is why" -- is how all of Rust handles errors. No exceptions, no try/catch, no invisible control flow leaping out of a function five layers down. Just a value you can see in the type signature and are forced to deal with. It is one of the things I missed most the last time I had to write some Python at speed.

If you come from Python, Java, or C#, brace yourself for a mental shift. In those languages a function that can fail usually hides that fact -- it looks like it returns a number, and then at runtime it throws and blows a hole through your call stack. In Rust the possibility of failure is written right into the return type, in broad daylight. That sounds like more typing (it is, a little), but the payoff is that you can never forget to handle an error, because the compiler will not let you pretend the failure case does not exist. I have lost quit some hours of my life to exceptions that surfaced three layers away from where the real problem was. Let us build up to why that is such a big deal.

Nota bene: keep Python in the back of your mind again, as we have all series. Python signals failure by raising -- control jumps somewhere else and the type checker never sees it coming. Rust signals failure by returning -- the error is an ordinary value flowing back through the normal path. That difference is the whole episode in one sentence.

Solutions to Episode 5's exercises

As always, let us clear last episode's homework first. All three were about building your own types, so they are a nice warm-up before we start returning them from fallible functions.

Exercise 1 wanted a Book struct with an associated new constructor and a summary method:

struct Book {
    title: String,
    author: String,
    pages: u32,
}

impl Book {
    fn new(title: &str, author: &str, pages: u32) -> Self {
        Book {
            title: title.to_string(),
            author: author.to_string(),
            pages,
        }
    }

    fn summary(&self) -> String {
        format!("{} by {} ({} pages)", self.title, self.author, self.pages)
    }
}

fn main() {
    let book = Book::new("Dune", "Frank Herbert", 412);
    println!("{}", book.summary()); // Dune by Frank Herbert (412 pages)
}

The interesting bit is new taking &str parameters but the struct storing owned String fields. We call .to_string() to turn each borrowed string slice into an owned String the struct can keep -- remember from episode 3 that the struct owns its fields, so it cannot borrow from something that might vanish. Note the field init shorthand on pages: because the parameter is also called pages, pages, is short for pages: pages. And summary takes &self because it only reads the book to format a line -- no need to consume or mutate anything.

Exercise 2 was the Coin enum with an explicit, wildcard-free match:

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn value_in_cents(coin: &Coin) -> u32 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

fn main() {
    let purse = [Coin::Penny, Coin::Quarter, Coin::Dime, Coin::Quarter, Coin::Nickel];
    let mut total = 0;
    for coin in &purse {
        total += value_in_cents(coin);
    }
    println!("total is {total} cents"); // 66
}

The whole point of forbidding the _ wildcard here is the safety it hands you for free. Because we spelled out all four variants and none more, the compiler checked the match is exhaustive. Now imagine a future you adds a Coin::HalfDollar variant -- this match stops compiling instantly, marching you straight to the one place that needs a new arm. A _ => 0 catch-all would have silently valued your new coin at zero cents and never said a word. That is the difference between a bug the compiler catches and a bug your users catch.

Exercise 3 was the traffic light that cannot hold an illegal state:

enum Light {
    Red,
    Yellow,
    Green,
}

fn next(light: Light) -> Light {
    match light {
        Light::Red => Light::Green,
        Light::Green => Light::Yellow,
        Light::Yellow => Light::Red,
    }
}

impl Light {
    fn seconds(&self) -> u32 {
        match self {
            Light::Red => 30,
            Light::Yellow => 5,
            Light::Green => 25,
        }
    }
}

fn main() {
    let light = Light::Red;
    println!("red stays {} seconds", light.seconds()); // 30
    let light = next(light);   // Red -> Green (old `light` is moved in)
    println!("green stays {} seconds", light.seconds()); // 25
}

The bonus asked you to sit with why an enum makes "a light that is somehow both red and green" impossible to even write down. A Light value is exactly one of the three variants, full stop -- there is no combination, no in-between, no "both flags set" the way there would be with three separate booleans. The illegal state is not "handled", it is unrepresentable, which is the deepest idea from last episode showing up in your own code. Also notice next takes light by value and we rebind with let light = next(light) -- the old value is moved in and consumed, and the shiny new state shadows the name. Right, on to failure!

Why values instead of exceptions

Let me make the case before the syntax, because the why is the important part. In an exception language, this Python looks totally innocent:

def read_port(text):
    return int(text)   # raises ValueError on "abc", but you cannot see that here

Nothing in that signature warns you it can explode. The caller has to either know from the docs (or from getting burned) that int() throws, or wrap everything in a try out of paranoia. The failure is invisible until runtime. Rust refuses to play that game. A function that can fail says so in its return type, and the workhorse for that is an enum from the standard library called Result:

enum Result<T, E> {
    Ok(T),     // success, carrying a value of type T
    Err(E),    // failure, carrying an error of type E
}

Look familiar? It is exactly the Outcome enum I sketched last episode, just generic over the success type T and the error type E. Ok(value) means it worked and here is your T; Err(problem) means it failed and here is your E. That is the entire mechanism. Because the return type literally says Result<i32, ParseIntError>, the caller can see that parsing might fail and, better yet, cannot get at the i32 without first acknowledging the error case. The failure is not hiding -- it is right there in the type, in broad daylight.

Result in practice, with match

Here is a real fallible function. The standard library's str::parse hands back a Result, so we get to decide what to do with both arms:

use std::num::ParseIntError;

fn double(text: &str) -> Result<i32, ParseIntError> {
    match text.parse::<i32>() {
        Ok(n) => Ok(n * 2),
        Err(e) => Err(e),
    }
}

fn main() {
    match double("21") {
        Ok(n) => println!("got {n}"),          // got 42
        Err(e) => println!("bad input: {e}"),
    }
    match double("oops") {
        Ok(n) => println!("got {n}"),
        Err(e) => println!("bad input: {e}"),   // bad input: invalid digit found in string
    }
}

Same match skill from episode 4, now branching on Ok versus Err and destructuring the payload out of each. When the text parses, we get Ok(n), double it, and re-wrap it as Ok(n * 2). When it does not, we get Err(e) and pass the error straight back to our own caller. The caller in main then has to match again to get at the number, which means there is no code path where you use a parsed value without having dealt with the possibility that parsing failed. The type system nailed the door shut. Having said that, writing match ... Ok(n) => Ok(...), Err(e) => Err(e) on every single fallible call would get tiresome fast. Rust knows this, and gives us a shortcut that is honestly one of my favourite features in the whole language.

The ? operator: propagation without ceremony

That Err(e) => Err(e) arm -- "if it failed, stop and return the same error to my caller" -- is so common that Rust has a one-character operator for it: ?. Slap ? on the end of any expression that returns a Result, and it means "if this is Ok, unwrap the value and carry on; if it is Err, return that error from the whole function right now". Watch our double collapse:

use std::num::ParseIntError;

fn double(text: &str) -> Result<i32, ParseIntError> {
    let n = text.parse::<i32>()?;   // Ok -> n gets the number; Err -> early return
    Ok(n * 2)
}

fn main() {
    println!("{:?}", double("21"));   // Ok(42)
    println!("{:?}", double("oops")); // Err(ParseIntError { .. })
}

That single ? replaced the entire four-line match. Read text.parse::<i32>()? as: try to parse; on success, n is the i32; on failure, bail out of double immediately handing the error to its caller. The magic is that it is not magic at all -- ? is pure syntax sugar for the match we wrote by hand a moment ago. And crucially, unlike a hidden exception, the ? is visible: you can literally see every spot where the function might exit early, because each one is marked with that little question mark. The error path is explicit, it is just not verbose.

Where ? really shines is chaining several fallible steps, each of which might fail:

use std::num::ParseIntError;

fn sum_two(a: &str, b: &str) -> Result<i32, ParseIntError> {
    let x = a.parse::<i32>()?;   // bail here if `a` is junk
    let y = b.parse::<i32>()?;   // or here if `b` is junk
    Ok(x + y)
}

fn main() -> Result<(), ParseIntError> {
    let total = sum_two("40", "2")?;
    println!("total {total}");   // total 42
    Ok(())
}

Two things worth pausing on. First, sum_two reads like the happy path -- parse a, parse b, add them -- with the failure handling tucked into three unobtrusive ? marks, yet every error is still fully accounted for. Second, look at fn main() -> Result<(), ParseIntError>: yes, main itself can return a Result. That lets you use ? right at the top level, and the final Ok(()) is just "everything succeeded, and there is no meaningful value to return" -- the () is Rust's empty unit type. If main returns an Err, the program exits with a non-zero status and prints the debug form of the error. Handy for small tools.

Option is for absence, Result is for failure

We have been using Option since episode 1, and it is easy to blur it together with Result. They are siblings -- both enums, both about "the value might not be there" -- but they answer different questions:

  • Option<T> is Some(T) or None. Use it when a value can be legitimately absent, and absence is not an error. Looking up a key that is not in a map. The first character of a possibly-empty string. There is nothing wrong, the thing simply is not there.
  • Result<T, E> is Ok(T) or Err(E). Use it when an operation can fail, and you want to explain why. Parsing bad input, a file that would not open, a network call that timed out.

The lovely part is that ? works on Option too, inside a function that returns an Option:

fn first_char(text: &str) -> Option<char> {
    text.chars().next()   // None if the string is empty
}

fn shout_first(text: &str) -> Option<String> {
    let c = first_char(text)?;   // None -> early return None
    Some(c.to_uppercase().to_string())
}

fn main() {
    println!("{:?}", shout_first("hello")); // Some("H")
    println!("{:?}", shout_first(""));      // None
}

Here ? on an Option means "if Some, give me the inner value; if None, return None from the whole function". Exactly the same early-exit shape as with Result, just for absence instead of failure. Choosing the right one of the two is a real design decision, and Rust APIs are consistent about it: a method named find returns an Option (the thing might not be there), while a method named parse returns a Result (it might fail, and you deserve to know why).

Bridging Option and Result

Sometimes you have an Option but your function wants to return a Result (because at this boundary, absence really is an error worth explaining). The bridge is ok_or / ok_or_else, which turns None into an Err of your choosing and Some(x) into Ok(x):

fn lookup(id: u32) -> Result<String, String> {
    let names = [(1, "alice"), (2, "bob")];
    names
        .iter()
        .find(|(key, _)| *key == id)          // Option
        .map(|(_, name)| name.to_string())    // Option
        .ok_or_else(|| format!("no user with id {id}"))  // Option -> Result
}

fn main() {
    println!("{:?}", lookup(1)); // Ok("alice")
    println!("{:?}", lookup(9)); // Err("no user with id 9")
}

Read the chain top to bottom: find gives us an Option (the id might not be in the list), map transforms the Some case into an owned String while leaving None untouched, and ok_or_else finally converts that Option<String> into a Result<String, String> by supplying an error message for the None case. I used ok_or_else (which takes a closure) rather than ok_or (which takes a plain value) because building that format! string only makes sense when we actually failed -- no point allocating an error message on the happy path. That "only pay for the error when there is an error" habit is very idiomatic Rust. (Closures get their own full episode later, so do not sweat the || ... syntax yet.)

unwrap, expect, and when panic is actually right

So far we have handled every error politely. But sometimes an error genuinely should crash the program -- because it means a bug, or a broken assumption that makes continuing pointless. For that Rust has panic!, and two convenience methods that panic for you: unwrap and expect.

fn main() {
    let n: i32 = "42".parse().unwrap();
    // unwrap: give me the Ok value, or PANIC if it is Err
    let m: i32 = "42".parse().expect("config port must be an integer");
    // expect: same, but panic with YOUR message for context
    println!("{n} and {m}"); // 42 and 42
}

Both unwrap and expect say "I am certain this is Ok, and if I am wrong, crash". The difference is that expect lets you attach a message explaining what you were certain about, which makes the panic message far more useful when your certainty turns out to be misplaced at 3 in the morning. So use expect over unwrap in real code -- future-you will thank present-you.

When is panicking the right choice? My rule of thumb: panic for bugs and broken invariants (an index that is provably in range, a config file baked into the binary that must be valid), and return a Result for expected, recoverable failures (user input, files, networks -- anything the outside world controls). A user typing "abc" where a number goes is not a bug in your program, so do not crash the whole thing over it -- hand back a Result and let the caller recover.

And often you do not need to panic or propagate -- you just want a sensible default. The unwrap_or family covers that:

fn main() {
    let port: u32 = "not a number".parse().unwrap_or(8080);
    println!("port {port}"); // 8080

    let count: u32 = "".parse().unwrap_or_default();
    println!("count {count}"); // 0  (u32's default)
}

unwrap_or(8080) means "the parsed value, or 8080 if it failed", and unwrap_or_default() falls back to the type's default (0 for numbers, empty for strings, and so on). These are perfect for "I would prefer the parsed value but I have a fine fallback", turning a possible failure into a guaranteed value without a match in sight.

Building your own error type

Returning Result<_, String> (an error that is just a text message) is fine for a script, but real programs want a typed error so callers can tell failures apart and react differently to each. The idiomatic move is a custom enum, one variant per way things can go wrong. Let me build a tiny config-value parser that can fail in two distinct ways -- the input is not a number, or the number is out of the allowed range -- and wire it up the full, proper way:

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

#[derive(Debug)]
enum ConfigError {
    NotANumber(ParseIntError),   // wraps the underlying parse failure
    OutOfRange(i64),             // carries the offending value
}

// 1. Display: the human-readable message
impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            ConfigError::NotANumber(e) => write!(f, "not a number: {e}"),
            ConfigError::OutOfRange(n) => write!(f, "{n} is out of range (expected 1..=100)"),
        }
    }
}

// 2. Error: marks this as a first-class error type (needs Debug + Display)
impl std::error::Error for ConfigError {}

// 3. From: lets `?` convert a ParseIntError into our ConfigError automatically
impl From<ParseIntError> for ConfigError {
    fn from(e: ParseIntError) -> Self {
        ConfigError::NotANumber(e)
    }
}

fn parse_level(text: &str) -> Result<i64, ConfigError> {
    let n: i64 = text.parse()?;   // ParseIntError -> ConfigError, thanks to From
    if !(1..=100).contains(&n) {
        return Err(ConfigError::OutOfRange(n));
    }
    Ok(n)
}

fn main() {
    for input in ["50", "999", "abc"] {
        match parse_level(input) {
            Ok(n) => println!("{input:>4} -> level {n}"),
            Err(e) => println!("{input:>4} -> error: {e}"),
        }
    }
}

There is a lot packed in there, so let me walk the three impls, because together they are the standard Rust recipe for a proper error type:

  1. Display gives your error a human-readable message -- it is what {e} prints. We match on the variant and write! an appropriate line for each. (#[derive(Debug)] on top handles the developer-facing {:?} form; Display is the user-facing {} form.)
  2. impl std::error::Error for ConfigError {} -- an empty impl! -- marks the type as a bona fide error. This is what lets it slot into the wider error ecosystem (return it as a Box<dyn Error>, use it with error libraries, and so on). It requires Debug and Display, which is exactly why we supplied both first.
  3. From<ParseIntError> is the clever one. It teaches Rust how to turn a ParseIntError into a ConfigError. And here is the payoff: when you write text.parse()? inside a function returning Result<_, ConfigError>, the ? operator automatically calls From::from to convert the parse error into your error type on the way out. That is why one plain ? bridges two different error types with zero fuss. The OutOfRange case we build and return by hand, because that failure is our own business logic, not someone else's error to convert.

Run it and you get 50 -> level 50, 999 -> out of range, and abc -> not a number, each failure clearly typed and clearly described. That is a real, grown-up error type in about twenty lines, and it scales: add a new failure mode, add a variant, add a Display arm, and the compiler chases down every match that needs updating -- the same exhaustiveness gift we keep getting from enums.

Try it yourself

Three exercises, gentle to chewier. Full solutions open the next episode, as always.

  1. Write a function parse_age(text: &str) -> Result<u32, std::num::ParseIntError> that parses a string to a u32 and returns it doubled, using the ? operator (not a match). Call it from main on both "20" and "twenty" and print each result with {:?} so you can see the Ok and the Err.

  2. Write safe_divide(a: i32, b: i32) -> Result<i32, String> that returns Ok(a / b) normally but Err(String::from("division by zero")) when b is 0 (dividing by zero would otherwise panic). Then write a second function that calls it twice with ? and returns the sum of two divisions, so you see error propagation across calls.

  3. Build a small custom error type. Define enum ParsePointError { Missing, BadNumber }, implement Display for it, and write parse_point(text: &str) -> Result<(i32, i32), ParsePointError> that splits a string like "3,4" on the comma (use text.split(',')), returns ParsePointError::Missing if there are not exactly two parts, and ParsePointError::BadNumber if either part fails to parse to an i32. Test it on "3,4", "3", and "3,x". Bonus: add impl std::error::Error for ParsePointError {} and confirm it still compiles.

Exercise 3 is the one to linger on -- it is the full custom-error recipe from this episode in your own hands, minus the From shortcut (which you can add for extra credit).

So what did we actually cover?

  • Rust models failure as a value, the Result<T, E> enum (Ok or Err), instead of throwing exceptions -- so the possibility of failure is visible in the type and cannot be silently ignored.
  • The ? operator propagates errors in one character: unwrap on Ok, early-return on Err. It is plain sugar for a match, and it works on Option too. main itself can return a Result.
  • Option is for absence (Some/None), Result is for failure (Ok/Err); ok_or_else bridges an Option into a Result when absence becomes an error worth explaining.
  • unwrap / expect panic on the error case (prefer expect for its message); unwrap_or / unwrap_or_default supply a fallback. Panic for bugs and broken invariants, return a Result for anything the outside world controls.
  • A proper custom error type is an enum plus three impls: Display (human message), Error (the marker trait), and From (so ? converts foreign errors into yours automatically).

Error handling is where Rust's whole "make the compiler your ally" philosophy stops being abstract and starts being something you type every single day. You now have the tools to write functions that fail honestly -- announcing it in their signature, forcing callers to reckon with it, and describing exactly what went wrong. Next time we start filling those Results and Options with real data at scale, reaching for the standard collections that hold many values at once. With ownership, control flow, your own types, and honest errors all in hand, you are genuinely ready for it.

Thanks for reading, and see you in the next one! ;-)

scipio@scipio

Leave Learn Rust Series (#6) - Error Handling to:

Written by

Does it matter who's right, or who's left?

Read more #stem posts


Best Posts From scipio

We have not curated any of scipio's posts yet. But you can encourage our curation team to review posts by visiting them regularly and by referring other readers. Because we give priority to frequently read content.

More Posts From scipio