How to use thiserror crate in Rust error derive

Use the thiserror derive macro to automatically implement error traits and define display messages for custom error enums in Rust.

The boilerplate trap

You are building a tool that parses a configuration file. The function returns a Result. You define an enum for the errors. Now you need to implement std::error::Error and std::fmt::Display. You write the impl Display block. You format a string. You miss a closing brace. The compiler rejects you with E0277 because your type fails the trait bounds required by the result type. You fix the brace. You realize you also need impl From<std::io::Error> so the ? operator works. You write that impl. You copy-paste the pattern for serde_json::Error. You have three error types in your crate. You just wrote forty lines of boilerplate. The logic is identical across all three. You need a way to declare the error structure and let the compiler generate the trait implementations.

This is the job of thiserror. The crate provides a derive macro that generates the standard error traits based on attributes you add to your enum. You describe the error variants and the messages. The macro writes the impl blocks. You get type-safe errors with zero boilerplate.

How the macro works

thiserror is a procedural macro. You add #[derive(Error)] to your enum. The macro comes from thiserror::Error, not std::error::Error. The naming convention keeps the import explicit and avoids confusion with the standard trait. The macro scans your enum variants and looks for attributes like #[error(...)] and #[from].

When you compile, the macro expands into the code you would otherwise write by hand. It generates impl std::fmt::Display for the enum. It generates impl std::error::Error. If you use #[from], it generates impl From<Source> for each variant. The generated code is equivalent to manual implementations, but the macro handles the formatting logic and trait bounds automatically.

Add thiserror to your Cargo.toml. Add #[derive(Debug)] to your error enum as well. The macro requires Debug to generate the code. If you forget it, the macro fails with a compile error. Convention is to always derive Debug on error types. It helps with logging and debugging. The community expects errors to be debuggable.

use thiserror::Error;

#[derive(Error, Debug)]
pub enum ConfigError {
    // Derive Error from thiserror to generate trait impls.
    // Derive Debug because the macro requires it and it aids debugging.
    
    #[error("file not found: {0}")]
    // The message string uses {0} to reference the first field.
    // The macro generates Display impl that formats this string.
    NotFound(String),

    #[error("invalid format: {0}")]
    // Placeholders map to fields by index.
    // {0} refers to the String field in this variant.
    InvalidFormat(String),
}

The #[error("...")] attribute defines the display message. The macro generates an impl std::fmt::Display that formats the string. Placeholders like {0} refer to the fields of the variant. The macro handles the formatting logic. You do not write the match statement. The macro also generates impl std::error::Error. This trait marks the type as a proper error in the Rust ecosystem. It enables methods like .source() and integration with panic handling.

Don't write impl Display by hand. The macro generates correct formatting code every time.

The #[from] attribute and the ? operator

The #[from] attribute is where thiserror saves the most time. When you add #[from] to a field, the macro generates an impl From<FieldType> for your error enum. This implementation allows the ? operator to convert the field type into your error automatically. Without #[from], you have to write the conversion manually or use .map_err. With #[from], the ? operator works out of the box.

This connects directly to Rust's error propagation style. You can return io::Result<T> and convert to your custom error just by returning the value with ?. The compiler sees the From impl and inserts the conversion. You also get the error chain for free. The macro sets the inner error as the source, so calling .source() on your error returns the wrapped error. This preserves the error context through the call stack.

use std::fs;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum AppError {
    #[error("IO error: {0}")]
    // #[from] generates impl From<std::io::Error>.
    // This allows ? to convert io::Error to AppError automatically.
    // The macro also sets io::Error as the source for error chaining.
    Io(#[from] std::io::Error),

    #[error("config key '{0}' is missing")]
    // Custom message with a placeholder.
    // No #[from] here because String is not an error type we convert from.
    MissingKey(String),

    #[error(transparent)]
    // transparent delegates Display to the inner error.
    // The message from serde_json::Error prints directly.
    // #[from] still generates the From impl for ? conversion.
    Parse(#[from] serde_json::Error),
}

fn read_config() -> Result<serde_json::Value, AppError> {
    // fs::read_to_string returns io::Result.
    // The ? operator converts io::Error to AppError::Io via #[from].
    let content = fs::read_to_string("config.json")?;
    
    // serde_json::from_str returns Result with serde_json::Error.
    // The ? operator converts to AppError::Parse via #[from].
    let value: serde_json::Value = serde_json::from_str(&content)?;
    
    Ok(value)
}

Use #[from] on every external error you wrap. The ? operator is the standard way to propagate errors in Rust.

Advanced attributes and conventions

thiserror supports attributes for more complex scenarios. The #[error(transparent)] attribute is useful when you want to wrap an error without adding a message layer. It delegates Display entirely to the inner error. If you use #[error("{0}")], you add a wrapper message. If you use transparent, the inner error prints itself. This is the preferred approach when the inner error's message is already clear and adding context would be redundant.

The #[source] attribute exposes a field as the error source without generating a From impl. Use #[source] when you want to keep the error chain for .source() but handle conversion manually or via a different mechanism. This is rare. Most code uses #[from] for both conversion and chaining.

The #[backtrace] attribute captures a backtrace for the variant. This requires the backtrace feature in Cargo.toml. Backtraces are heavy. They slow down execution and increase binary size. Use them only when you need stack traces for debugging production issues. Convention is to enable backtraces selectively, not on every error.

Convention aside: thiserror supports both #[error("...")] and #[display("...")]. Both work identically. The community convention is #[error]. Using #[display] works but looks odd to other Rust developers. Stick to #[error] for consistency.

use thiserror::Error;

#[derive(Error, Debug)]
pub enum DatabaseError {
    #[error("connection failed: {0}")]
    // Standard error message with placeholder.
    Connection(#[from] std::io::Error),

    #[error(transparent)]
    // Passthrough for driver errors.
    // The driver's message is already descriptive.
    Driver(#[from] tokio_postgres::Error),

    #[error("query timeout")]
    // Static message for a specific condition.
    // No fields, no conversion.
    Timeout,
}

Treat transparent as a passthrough. Use it when the inner error's message is already clear.

Pitfalls and compiler errors

Common mistakes trigger specific compiler errors. Forgetting #[derive(Debug)] causes the macro to fail. The error message points to the derive macro. Add Debug to fix it.

Using #[from] on multiple variants for the same source type creates a conflict. The macro generates duplicate impl From<Source> blocks. The compiler rejects this with E0119 (conflicting implementations of trait). You can only have one From impl per source type. If you need to convert the same error type to different variants, you must implement From manually or use a helper function.

Mixing #[from] and #[source] on the same field is redundant. #[from] already sets the source. Adding #[source] does nothing extra. The macro handles it, but it clutters the code. Use #[from] alone.

Using #[error] on a variant with no fields and no message is valid but produces an empty display string. This is usually a mistake. Add a message or remove the attribute if you rely on Debug output.

Check for duplicate #[from] attributes. The compiler will reject conflicting From implementations.

Decision matrix

Rust has several error handling crates. Choose based on your context.

Use thiserror when you are writing a library and need to expose a typed error enum. Libraries should define precise error types so consumers can match on variants. thiserror minimizes the boilerplate for defining those types.

Use anyhow when you are writing an application and just need a generic error type for the top level. Applications often aggregate errors from many sources. anyhow provides a flexible Result<T, anyhow::Error> that wraps any error with context. It is heavier but more convenient for app-level code.

Use manual implementations when you have a single error type with complex logic that the macro cannot handle. This is rare. Most error types fit the enum pattern. Manual impls are harder to maintain and prone to typos.

Use snafu when you need automatic context attachment and backtrace handling with less boilerplate than manual but more than thiserror. snafu generates context methods and handles backtraces automatically. It is a heavier dependency.

Libraries use thiserror. Applications use anyhow. Stick to the convention.

Where to go next