DEV Community

Cover image for Error Handling in Rust: From Panic to anyhow and thiserror
Olivia
Olivia

Posted on

Error Handling in Rust: From Panic to anyhow and thiserror

Error Handling in Rust: From Panic to anyhow and thiserror

Error handling in Rust can be a complex topic, especially for developers coming from languages with different error handling paradigms. Unlike languages that rely on exceptions, Rust provides several approaches to handling errors: from basic patterns like panic!, boolean returns, and Option types, to more sophisticated solutions using the Result enum, custom error types, and specialized crates like thiserror and anyhow.

In this comprehensive guide, we'll walk through the evolution of error handling in Rust, starting with simple approaches and progressively building up to production-ready patterns. By the end, you'll understand when to use each approach and how to implement robust error handling in your Rust applications.

Here's a preview of what we'll be building - a custom error type using thiserror that integrates seamlessly with anyhow:

use thiserror::Error;

#[derive(Error, Debug)]
pub enum EnrollmentError {
    #[error("Invalid student name (expected non-empty, got {found:?})")]
    InvalidStudentName { found: String },
    #[error("Invalid course name (expected non-empty, got {found:?})")]
    InvalidCourseName { found: String },
    #[error("Student {student_id} is already enrolled in course {course_id}")]
    DuplicateEnrollment { student_id: u32, course_id: u32 },
}
Enter fullscreen mode Exit fullscreen mode

The Example: A University Database

Throughout this guide, we'll use a practical example: a university database system that manages students, courses, and enrollments. This example provides realistic scenarios where different error handling approaches make sense.

Let's start by defining our base data structures:

use std::collections::HashMap;

pub struct Student {
    pub id: u32,
    pub name: String,
}

pub struct Course {
    pub id: u32,
    pub name: String,
}

pub struct Database {
    students: Vec<Student>,
    courses: Vec<Course>,
    // student_id -> course_ids
    enrollments: HashMap<u32, Vec<u32>>,
}

impl Database {
    pub fn new() -> Self {
        Database {
            students: Vec::new(),
            courses: Vec::new(),
            enrollments: HashMap::new(),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

We have a Student and Course struct, each with an ID and name. The Database struct manages collections of students and courses, plus a HashMap tracking which students are enrolled in which courses.

Evolution from Panic to Result

Now let's explore three different approaches to handling errors when adding a student to our database. Each approach has its trade-offs, and understanding them will help you choose the right pattern for your use case.

Approach 1: Using panic! (Not Recommended for Most Cases)

The simplest but most aggressive approach is to use panic! when something goes wrong. When a panic occurs, the program crashes immediately:

pub fn add_student(&mut self, id: u32, name: String) {
    if self.students.iter().any(|s| s.id == id) {
        panic!("Student with id {} already exists", id);
    }

    let student = Student::new(id, name);
    self.students.push(student);
}

pub fn demonstrate() {
    let mut db = Database::new();
    db.add_student(1, "Alice".to_string());
    db.add_student(1, "Bob".to_string()); // This will panic
}
// OUTPUT
// Adding new student 1 Alice
// Student with id 1 added
// Adding new student 1 Bob

// thread 'main' panicked at src/main.rs:42:17:
// Student with id 1 already exists
Enter fullscreen mode Exit fullscreen mode

Problems with this approach:

  • The entire program crashes on a recoverable error
  • No way for the caller to handle the error gracefully
  • Makes testing difficult since panics terminate the test
  • Not suitable for long-running services or applications

When to use it: Only for truly unrecoverable errors or during initial prototyping.

Approach 2: Returning Boolean Values

pub fn add_student(&mut self, id: u32, name: String) -> bool {
    println!("Adding new student {id} {name}");
    if self.students.iter().any(|s| s.id == id) {
        println!("Student with id {} already exists", id);
        return false;
    }

    let student = Student::new(id, name);
    self.students.push(student);
    return true;
}

pub fn demonstrate() {
    let mut db = Database::new();
    let added_alice = db.add_student(1, "Alice".to_string());
    let added_bob = db.add_student(1, "Bob".to_string());
    println!("Alice {}, Bob {}", added_alice, added_bob);
}
// OUTPUT
// Adding new student 1 Alice
// Adding new student 1 Bob
// Student with id 1 already exists
// Alice true, Bob false
Enter fullscreen mode Exit fullscreen mode

Improvements:

  • The program doesn't crash
  • Caller can check if the operation succeeded
  • Suitable for simple success/failure scenarios

Problems:

  • No information about why it failed
  • Caller must log or handle errors separately
  • Not composable with Rust's ? operator

When to use it: Simple cases where you only need to know success/failure and the context is obvious.

Approach 3: Using Result for Detailed Error Information

This is Rust's recommended approach. The Result type allows you to return either a success value or detailed error information:

pub fn add_student(&mut self, id: u32, name: String) -> Result<Student, String> {
    println!("Adding new student {id} {name}");

    if self.students.iter().any(|s| s.id == id) {
        return Err(format!("Student with id {} already exists", id));
    }

    let student = Student::new(id, name);
    self.students.push(student.clone());
    return Ok(student);
}

pub fn demonstrate() {
    let mut db = Database::new();
    let result_1 = db.add_student(1, "Alice".to_string());
    let result_2 = db.add_student(1, "Bob".to_string());
    println!("{:?}", result_1);
    println!("{:?}", result_2);
}
// OUTPUT
// Adding new student 1 Alice
// Adding new student 1 Bob
// Ok(Student { id: 1, name: "Alice" })
// Err("Student with id 1 already exists")
Enter fullscreen mode Exit fullscreen mode

Improvements:

  • Provides detailed error information to the caller
  • Works with Rust's ? operator for error propagation
  • Can be pattern matched for different error cases
  • Industry standard approach in Rust

Remaining limitations:

  • Using String for errors is not ideal (we'll improve this next)
  • No structured error types for programmatic handling

This Result approach is solid, but we can make it even better with specialized error handling crates.

Improving Error Handling with anyhow

The anyhow crate is designed for application code where you want excellent error messages and easy error propagation. Let's see how it improves our error handling.

Basic anyhow Usage

First, add anyhow to your Cargo.toml:

[dependencies]
anyhow = "1.0"
Enter fullscreen mode Exit fullscreen mode

Now let's refactor our code to use anyhow::Result instead of Result<Student, String>:

use anyhow::Result;

pub fn add_student(&mut self, id: u32, name: String) -> Result<Student> {
    println!("Adding new student {id} {name}");

    if self.students.iter().any(|s| s.id == id) {
        return Err(anyhow::anyhow!("Student with id {} already exists", id));
    }

    let student = Student::new(id, name);
    self.students.push(student.clone());
    return Ok(student);
}
Enter fullscreen mode Exit fullscreen mode

Why is this better?

  • Type flexibility: anyhow::Result can hold any error type, not just String
  • Better traits: Automatic implementation of Display, Debug, and other error traits
  • Composability: Can return different error types from the same function
  • Rich features: Sets us up to use anyhow's powerful context and chaining features

The real power of anyhow comes from its ability to add context to errors as they propagate up the call stack.

Adding Context with anyhow

One of anyhow's best features is the .context() method, which allows you to add contextual information as errors bubble up through your code. This is incredibly valuable for debugging complex systems.

Let's add input validation to our student addition function. First, we'll create a helper function to validate names:

We need to import the Context trait to use the .context() method:

use anyhow::{Result, Context};
Enter fullscreen mode Exit fullscreen mode

Now let's implement our validation function and update add_student:

fn validate_name(name: &str) -> Result<()> {
    if name.trim().is_empty() {
        return Err(anyhow::anyhow!("Name cannot be empty"));
    }
    Ok(())
}

pub fn add_student(&mut self, id: u32, name: String) -> anyhow::Result<Student> {
    println!("Adding new student {id} {name}");

    Self::validate_name(&name).context(format!("Failed to add student with id {}", id))?;

    if self.students.iter().any(|s| s.id == id) {
        return Err(anyhow::anyhow!("Student with id {} already exists", id));
    }

    let student = Student::new(id, name);
    self.students.push(student.clone());
    return Ok(student);
}

pub fn demonstrate() {
    let mut db = Database::new();
    let result_1 = db.add_student(1, "Alice".to_string());
    println!("{:?}\n", result_1);

    let result_2 = db.add_student(1, "Bob".to_string());
    println!("{:?}\n", result_2);

    let result_3 = db.add_student(2, "   ".to_string());
    println!("{:?}\n", result_3);
}
// OUTPUT
// Adding new student 1 Alice
// Ok(Student { id: 1, name: "Alice" })
//
// Adding new student 1 Bob
// Err(Student with id 1 already exists)
//
// Adding new student 2
// Err(Failed to add student with id 2
//
// Caused by:
//     Name cannot be empty)
Enter fullscreen mode Exit fullscreen mode

Notice how the error output now shows a hierarchy:

  1. Top-level context: "Failed to add student with id 2"
  2. Root cause: "Name cannot be empty"

This layered approach is incredibly helpful when debugging complex applications. Each layer of the call stack can add its own context, making it easy to understand not just what failed, but where and why.

Building Complex Error Chains

Let's look at a more realistic scenario: enrolling a student in a course. This operation involves multiple steps, each of which could fail:

  1. Ensure the student exists (or create them)
  2. Ensure the course exists (or create it)
  3. Check for duplicate enrollments
  4. Record the enrollment

Here's how we can use context at multiple levels to make errors maximally informative:

pub fn process_enrollment(
    &mut self,
    student_id: u32,
    student_name: String,
    course_id: u32,
    course_name: String,
) -> Result<()> {
    // Add student if needed
    if !self.students.iter().any(|s| s.id == student_id) {
        self.add_student(student_id, student_name)
            .context("Failed to add student during enrollment")?;
    }

    // Add course if needed
    if !self.courses.iter().any(|c| c.id == course_id) {
        self.add_course(course_id, course_name)
            .context("Failed to add course during enrollment")?;
    }

    // Check for duplicate enrollment
    let enrolled_courses = self.enrollments
        .entry(student_id).or_insert_with(Vec::new);

    if enrolled_courses.contains(&course_id) {
        return Err(anyhow::anyhow!(
            "Student {} is already enrolled in course {}",
            student_id, course_id
        )).context("Enrollment already exists");
    }

    enrolled_courses.push(course_id);
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

When errors occur, we see the full context chain:

// Example: Trying to enroll with invalid student name
db.process_enrollment(2, "   ".to_string(), 101, "Rust 101".to_string());

// OUTPUT:
// Err(Failed to add student during enrollment
// Caused by:
//     0: Failed to add student with id 2
//     1: Name cannot be empty)

// Example: Trying to duplicate enrollment
db.process_enrollment(1, "Alice".to_string(), 101, "Rust 101".to_string());

// OUTPUT:
// Err(Enrollment already exists
// Caused by:
//     Student 1 is already enrolled in course 101)
Enter fullscreen mode Exit fullscreen mode

The context chain builds naturally as errors propagate up:

  • The deepest error tells us what went wrong ("Name cannot be empty")
  • Middle layers tell us what operation was being attempted ("Failed to add student with id 2")
  • Top layer tells us the high-level operation ("Failed to add student during enrollment")

This makes debugging production issues much easier - you can see the entire context of what the application was trying to do when the error occurred.

When anyhow Isn't Enough: Custom Error Types with thiserror

While anyhow is excellent for application code, it has a limitation: errors are type-erased. This means consumers of your code can't match on specific error types to handle them differently.

This is fine for applications (where you typically just log errors), but problematic for libraries where users need to programmatically handle different error cases.

Introducing thiserror

The thiserror crate makes it easy to create custom error types with minimal boilerplate. Instead of writing manual implementations of the Error, Display, and other traits, you can derive them automatically.

First, add thiserror to your Cargo.toml:

[dependencies]
thiserror = "1.0"
Enter fullscreen mode Exit fullscreen mode

Now let's create a custom error enum for our enrollment system:

use thiserror::Error;

#[derive(Error, Debug)]
pub enum EnrollmentError {
    #[error("Invalid student name (expected non-empty, got {found:?})")]
    InvalidStudentName { found: String },
    #[error("Invalid course name (expected non-empty, got {found:?})")]
    InvalidCourseName { found: String },
    #[error("Student {student_id} is already enrolled in course {course_id}")]
    DuplicateEnrollment { student_id: u32, course_id: u32 },
    #[error("Student with id {0} already exists")]
    DuplicateStudent(u32),
    #[error("Course with id {0} already exists")]
    DuplicateCourse(u32),
}
Enter fullscreen mode Exit fullscreen mode

Let's break down what's happening here:

  • #[derive(Error, Debug)]: Automatically implements the Error and Debug traits
  • #[error("...")]: Defines the Display message for each variant
  • Named fields ({found:?}): Allow you to include data in error messages
  • Tuple variants (DuplicateStudent(u32)): Provide a concise syntax for single-field errors

Using Custom Error Types

Now we need to update our functions to return Result<T, EnrollmentError> instead of anyhow::Result. Note that we can no longer use .context() (that's an anyhow-specific feature), so our error variants themselves need to be descriptive:

fn validate_name(name: &str) -> Result<(), EnrollmentError> {
    if name.trim().is_empty() {
        return Err(EnrollmentError::InvalidStudentName {
            found: name.to_string(),
        });
    }
    Ok(())
}

pub fn add_student(&mut self, id: u32, name: String) -> Result<Student, EnrollmentError> {
    // This now returns EnrollmentError so we can use ? directly
    Self::validate_name(&name)?;

    if self.students.iter().any(|s| s.id == id) {
        return Err(EnrollmentError::DuplicateStudent(id));
    }

    let student = Student::new(id, name);
    self.students.push(student.clone());
    return Ok(student);
}

pub fn add_course(&mut self, id: u32, name: String) -> Result<Course, EnrollmentError> {
    if name.trim().is_empty() {
        return Err(EnrollmentError::InvalidCourseName {
            found: name.to_string(),
        });
    }

    if self.courses.iter().any(|c| c.id == id) {
        return Err(EnrollmentError::DuplicateCourse(id));
    }

    let course = Course::new(id, name);
    self.courses.push(course);
    return Ok(self.courses.last().unwrap().clone());
}

pub fn process_enrollment(
    &mut self,
    student_id: u32,
    student_name: String,
    course_id: u32,
    course_name: String,
) -> Result<(), EnrollmentError> {
    if !self.students.iter().any(|s| s.id == student_id) {
        self.add_student(student_id, student_name)?;
    }

    if !self.courses.iter().any(|c| c.id == course_id) {
        self.add_course(course_id, course_name)?;
    }

    let enrolled_courses = self.enrollments
        .entry(student_id).or_insert_with(Vec::new);

    if enrolled_courses.contains(&course_id) {
        return Err(EnrollmentError::DuplicateEnrollment {
            student_id,
            course_id,
        });
    }

    enrolled_courses.push(course_id);
    Ok(())
}

pub fn demonstrate() {
    let mut db = Database::new();

    let result_1 = db.process_enrollment(1, "Alice".to_string(), 101, "Rust 101".to_string());
    println!("{:?}\n", result_1);

    let result_2 = db.process_enrollment(1, "Alice".to_string(), 102, "Advanced Rust".to_string());
    println!("{:?}\n", result_2);

    let result_3 = db.process_enrollment(2, "   ".to_string(), 101, "Rust 101".to_string());
    println!("{:?}\n", result_3);

    let result_4 = db.process_enrollment(1, "Alice".to_string(), 101, "Rust 101".to_string());
    println!("{:?}\n", result_4);
}
// OUTPUT
// Ok(())
//
// Ok(())
//
// Err(InvalidStudentName { found: "   " })
//
// Err(DuplicateEnrollment { student_id: 1, course_id: 101 })
Enter fullscreen mode Exit fullscreen mode

Benefits of Custom Error Types

The error messages are now structured and actionable. More importantly, library consumers can handle different errors programmatically:

match db.process_enrollment(2, "   ".to_string(), 101, "Rust 101".to_string()) {
    Ok(()) => println!("Enrollment successful"),
    Err(EnrollmentError::InvalidStudentName { found }) => {
        println!("Please provide a valid student name, got: {:?}", found);
        // Show user a helpful error message
    }
    Err(EnrollmentError::DuplicateEnrollment { student_id, course_id }) => {
        println!("Student {} is already enrolled in course {}", student_id, course_id);
        // Maybe redirect to the course page instead of erroring
    }
    Err(e) => {
        println!("Unexpected error: {}", e);
        // Generic error handling
    }
}
Enter fullscreen mode Exit fullscreen mode

With custom error types, library clients can:

  • Pattern match on specific error variants
  • Access error fields programmatically (like student_id, course_id)
  • Make informed decisions based on error type
  • Provide targeted error messages to end users

When to Use Each Approach

Here's a quick decision guide:

Scenario Recommended Approach Why
Quick prototyping panic! or unwrap() Fast iteration, acceptable for throwaway code
Simple success/failure bool Minimal overhead when details don't matter
Application error handling anyhow Great error messages, easy context chaining
Library public API thiserror Type-safe, allows consumers to handle errors programmatically
Internal application code Result<T, String> or anyhow Balance of simplicity and information

Key insight: anyhow is perfect for applications where you control the entire error handling flow. thiserror shines when you're building libraries where consumers need to handle your errors programmatically.

Combining anyhow and thiserror

In practice, many projects use both crates together:

  • Library code: Use thiserror to define public error types
  • Application code: Use anyhow to add context and handle errors from multiple libraries
  • The boundary: anyhow can wrap any error type (including thiserror errors), giving you the best of both worlds
// In your library (using thiserror)
pub fn risky_operation() -> Result<Value, MyLibraryError> { /* ... */ }

// In your application (using anyhow)
fn main() -> anyhow::Result<()> {
    let value = risky_operation()
        .context("Failed during application startup")?;
    // anyhow automatically wraps MyLibraryError and adds context
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Error handling in Rust evolves as your code matures:

  1. Start simple: Use Result with String errors or even panic! for prototypes
  2. Add structure: Graduate to anyhow for better error messages and context
  3. Think about consumers: Use thiserror when building libraries that others will use
  4. Combine approaches: Use both crates together in larger projects

The Rust error handling ecosystem might seem complex at first, but each tool has a clear purpose. By understanding the progression from basic patterns to sophisticated error types, you can choose the right approach for each situation.

Remember: good error messages save hours of debugging. Invest in error handling early, and your future self (and your users) will thank you.


Want to get better at Rust, 1% at a time? I send out a Rust fundamentals thread every Thursday - clear, practical examples, and beginner-friendly.

👉 Join the newsletter here

Top comments (0)