How to Create Custom Error Types in Rust

Create a custom error enum in Rust and implement Display and Error traits to define specific application failure modes.

When standard errors aren't specific enough

You are building a command-line tool that downloads a file and saves it to disk. The download fails. Your code panics with a wall of text about TCP connection resets. Or worse, you catch the error and print "Something went wrong." Neither helps the user. You need to distinguish between a network glitch, a missing file, and a permission denied. You need your own error type.

Rust's standard library provides errors like io::Error and ParseIntError. These cover system operations and parsing. They do not cover your application logic. If your app requires a configuration file and the file is missing, io::Error tells you the OS couldn't open a path. It does not tell you that the configuration is required for the app to run. A custom error type bridges that gap. It wraps lower-level errors and adds your domain context.

The anatomy of a custom error

A custom error type in Rust is an enum that implements two traits: std::fmt::Display and std::error::Error. The enum defines the possible failure modes. Each variant represents a distinct way things can go wrong. The traits make the enum compatible with the rest of the ecosystem.

Think of a hospital triage system. A generic error is like a patient shouting "I hurt!" The triage nurse needs more detail. Is it a broken arm? A heart attack? An allergic reaction? A custom error type is the triage form. It forces the code to specify the category of failure. The Display trait is the label on the form that the doctor reads. The Error trait is the stamp that says this form belongs in the medical records system, allowing it to be processed by standard tools.

The community convention is to derive Debug on every error type. Debug output is for developers reading logs. If you skip Debug, tools that print errors will fall back to a generic representation, which loses information. Always include #[derive(Debug)].

Minimal working example

Here is the skeleton. It defines an error with two variants, implements the required traits, and returns the error.

use std::fmt;
use std::io;

/// Errors that can occur in the application.
#[derive(Debug)]
enum AppError {
    /// An I/O operation failed.
    Io(io::Error),
    /// A requested item does not exist.
    NotFound(String),
}

// Implement Display to control the user-facing message.
impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            AppError::Io(e) => write!(f, "IO error: {}", e),
            AppError::NotFound(msg) => write!(f, "Item not found: {}", msg),
        }
    }
}

// Implement Error to mark this type as a standard error.
impl std::error::Error for AppError {}

fn main() -> Result<(), AppError> {
    // Return a custom error to demonstrate usage.
    Err(AppError::NotFound("user".to_string()))
}

How the pieces fit together

The enum variants hold the data. AppError::Io wraps an io::Error. This preserves the underlying system error. AppError::NotFound holds a String describing what was missing. The choice of String avoids lifetime complications. Storing &str would require the error to borrow from somewhere, which makes returning the error from functions difficult. Storing String allocates, but errors are rare events. The allocation cost is negligible compared to the complexity of managing lifetimes in error types.

The Display implementation controls how the error prints when you use {} in a format string. This is the message the user sees. The match arm for AppError::Io delegates to the inner error's display. This keeps the output readable. The NotFound arm formats the message directly.

The Error trait is a marker. It tells the compiler and other crates that this type behaves like an error. Implementing it unlocks the ? operator and allows the type to be stored in Box<dyn std::error::Error>. The empty body impl std::error::Error for AppError {} is sufficient for basic usage. The trait has optional methods like source(), which you can implement later to chain errors.

Build the dashboard with specific warning lights. Don't just show a generic red indicator.

Making errors ergonomic with From

The ? operator is the primary way to propagate errors in Rust. It works by calling From::from to convert the error type. If you return AppError but try to use ? on an io::Error, the compiler rejects the code. You must implement From<io::Error> for AppError.

// Allow the ? operator to convert io::Error into AppError::Io.
impl From<io::Error> for AppError {
    fn from(err: io::Error) -> Self {
        AppError::Io(err)
    }
}

fn read_config() -> Result<String, AppError> {
    // The ? operator uses From to convert io::Error to AppError automatically.
    let content = std::fs::read_to_string("config.txt")?;
    Ok(content)
}

The From implementation maps the foreign error to your variant. When read_to_string returns an Err(io::Error), the ? operator calls AppError::from(err). This returns AppError::Io(err), which matches the return type of read_config. The error bubbles up cleanly.

The community convention is to implement From rather than Into. Into is automatically implemented for any type that implements From. Writing From is explicit and preferred. You will rarely write these implementations by hand in production code. The thiserror crate generates them using attributes. Start with manual implementations to understand the mechanics, then switch to thiserror for real projects.

The ? operator is the payoff. Make From work so you can use it.

Adding context and handling chains

Errors often need more than just the underlying cause. You might want to record the file path that failed, or the user ID that was invalid. You can add fields to the variant.

#[derive(Debug)]
enum AppError {
    Io(io::Error),
    /// Config file could not be read.
    ConfigRead {
        source: io::Error,
        path: String,
    },
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            AppError::Io(e) => write!(f, "IO error: {}", e),
            AppError::ConfigRead { source, path } => {
                write!(f, "Failed to read config at {}: {}", path, source)
            }
        }
    }
}

impl std::error::Error for AppError {
    // Return the underlying cause for error chaining.
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            AppError::Io(e) => Some(e),
            AppError::ConfigRead { source, .. } => Some(source),
        }
    }
}

The ConfigRead variant stores both the io::Error and the path. The Display implementation uses both fields to create a helpful message. The source() method returns the underlying error. This enables error chaining. Tools like color-eyre and anyhow use source() to print the full chain of causes. Implementing source() is optional but recommended for errors that wrap other errors. It allows debugging tools to trace the failure back to the root.

Convention aside: When you store a wrapped error in a struct-like variant, name the field source. This signals to readers that the field holds the cause. It also matches the naming used by the thiserror crate.

Treat the source field as the breadcrumb trail. If you can't trace the error back to the root, the chain is broken.

Pitfalls and compiler feedback

Custom error types introduce a few common traps. The compiler usually catches them, but the messages can be dense.

If you try to use ? on an error type without implementing From, the compiler rejects you with E0277 (the trait bound AppError: From<std::io::Error> is not satisfied). The compiler is telling you it does not know how to convert the system error into your app error. Add the From implementation to fix this.

If you forget to implement Display, you can still use the error, but printing it with {} falls back to Debug. The output might include internal details that confuse users. Always implement Display for user-facing errors.

If you forget to derive Debug, tools that rely on debug formatting will fail or produce unhelpful output. The compiler does not enforce Debug, but the ecosystem expects it. Add #[derive(Debug)] to every error type.

Storing String in error variants allocates memory. If you are creating errors in a tight loop, this adds up. Storing &str avoids allocation but introduces lifetimes. Lifetimes make the error type harder to return from functions because the error must borrow from data that lives long enough. Pick String for simplicity unless profiling proves allocation is the bottleneck. Errors should be rare. Optimize for clarity first.

If the compiler complains about trait bounds, check your From impl. The error message is usually precise about which conversion is missing.

Choosing the right approach

Rust offers several ways to handle errors. The right choice depends on where you are in the codebase and what you need to do with the error.

Use a custom enum when your application has distinct failure modes that require different handling logic, like retrying a network error versus aborting on a configuration error.

Use the thiserror crate when you want to define the same structure with less boilerplate; it generates Display, Error, and From implementations automatically using attributes.

Use anyhow::Error or Box<dyn std::error::Error> when you are writing a command-line binary and need to propagate errors from dependencies without defining a custom type for every crate boundary.

Use a struct instead of an enum when an error contains multiple independent pieces of data that always appear together, rather than mutually exclusive variants.

Pick the tool that matches the boundary. Libraries define enums. Binaries use anyhow.

Where to go next