How to implement std error Error trait
You've built a custom error type. It holds a message. It implements Debug. You return it from a function, and everything compiles. Then you try to use that error with a library that expects std::error::Error, or you try to box it as Box<dyn Error>, and the compiler rejects you. The error isn't just a value. It's a citizen of the error ecosystem. To join that ecosystem, your type needs the Error trait.
The Error trait is the standard marker for error types in Rust. It tells the compiler and other libraries that your type can be treated as an error. It enables dynamic dispatch, error chaining, and integration with tools like anyhow or miette. Without it, your error type is isolated. You can return it, but you can't pass it through generic error handlers or trait objects.
The Error passport
Think of std::error::Error like a passport. You can move around your local scope without one. But the moment you try to cross a border into generic error handling, the border guard checks for that passport. If your type doesn't implement Error, the guard stops you.
The trait also defines how errors link together. Real-world failures often have a cause. A database query fails because the connection dropped. The connection dropped because the network timed out. The Error trait provides the source method to expose that chain. When you implement Error, you're not just getting a stamp; you're declaring how your error fits into the chain of causality.
Minimal implementation
The Error trait has no required methods. It has trait bounds. The trait definition requires that any type implementing Error must also implement Debug and Display. You satisfy the trait by implementing those two, then adding an empty impl Error block.
use std::error::Error;
use std::fmt;
// Debug is required by the Error trait bounds.
// Derive is usually sufficient for developer-facing output.
#[derive(Debug)]
struct MyError {
msg: String,
}
// Display is required by the Error trait bounds.
// This is the user-facing message.
impl fmt::Display for MyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.msg)
}
}
// The Error trait itself has no required methods.
// source() has a default implementation returning None.
impl Error for MyError {}
The source method returns Option<&(dyn Error + 'static)>. The default implementation returns None, which means your error has no inner cause. If your error wraps another error, you override source to return it.
Convention aside: In production code, you'll rarely write impl Error by hand. The community standard is the thiserror crate, which generates the trait implementations from attributes. But knowing the manual form is essential. It helps you debug when thiserror does something unexpected, and it's the only option in no-std environments or when you need zero dependencies.
The trait is the badge. Display and Debug are the uniform. Wear them all.
What the compiler checks
When you add impl Error, the compiler verifies the bounds. It checks that Debug is implemented. It checks that Display is implemented. If either is missing, you get E0277 (the trait bound is not satisfied). The compiler will point to the impl Error block and tell you which bound failed.
Once the bounds are satisfied, your type becomes a valid target for trait objects. You can box it as Box<dyn Error>. You can return it from functions that expect impl Error. You can use the ? operator to convert it into other error types that implement From<MyError>.
The Error trait also enables error chaining. Libraries that inspect errors can call source() to walk down the chain. This is how tools print the full stack of causes. If you don't implement source, the chain stops at your error. The root cause is lost.
Debug and Display serve different audiences. Debug is for developers. It appears in logs, panics, and debuggers. It should be precise and technical. Display is for users. It appears in error messages shown to the end user. It should be clear and actionable. The Error trait requires both because an error is both a technical artifact and a user message.
Don't treat Display as a shortcut for Debug. They have distinct roles. Implement both with intent.
Chaining errors with source
Real errors often wrap other errors. A configuration loader might fail because of an I/O error. A parser might fail because of a network timeout. To preserve the root cause, store the inner error and expose it via source.
use std::error::Error;
use std::fmt;
use std::fs;
#[derive(Debug)]
struct ConfigError {
msg: String,
// Store the inner error to chain it.
source: Option<std::io::Error>,
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Config error: {}", self.msg)
}
}
impl Error for ConfigError {
// Override source to return the inner error.
fn source(&self) -> Option<&(dyn Error + 'static)> {
// Map the Option<io::Error> to Option<&dyn Error>.
self.source.as_ref().map(|e| e as &(dyn Error + 'static))
}
}
fn load_config() -> Result<String, ConfigError> {
fs::read_to_string("config.toml").map_err(|e| ConfigError {
msg: "Failed to read file".to_string(),
source: Some(e),
})
}
The source method returns a reference to a trait object. You must coerce the concrete type to &(dyn Error + 'static). The as cast performs that coercion. The + 'static lifetime bound ensures the error doesn't borrow from the stack. Most error types satisfy this automatically.
When you chain errors, tools can walk the chain. Here's how you iterate it manually:
fn print_error_chain(err: &(dyn Error + 'static)) {
println!("Error: {}", err);
let mut source = err.source();
while let Some(cause) = source {
println!("Caused by: {}", cause);
source = cause.source();
}
}
This loop prints the top-level error, then drills down through source until it hits None. Libraries like anyhow and miette use this pattern to render rich error reports. If you don't implement source, the chain breaks, and the user sees only the wrapper message.
Chaining errors preserves the root cause. Don't swallow the inner error; expose it via source.
Pitfalls and compiler errors
Implementing Error is straightforward, but a few traps exist.
If you forget Debug or Display, the compiler rejects the impl Error block with E0277 (trait bound not satisfied). The error message points to the missing bound. Add #[derive(Debug)] or implement Display to fix it.
If you implement source but return the wrong type, you get E0308 (mismatched types). The signature demands Option<&(dyn Error + 'static)>. Returning &io::Error fails because the compiler needs a trait object. Use the as &(dyn Error + 'static) cast to coerce the type.
Some crates expect Send and Sync on error trait objects. The Error trait does not imply Send or Sync. If you return Box<dyn Error> from a function that runs in a multi-threaded context, you might need Box<dyn Error + Send + Sync>. If your error type holds non-Send data, like a Rc, it won't satisfy those bounds. Use Arc instead of Rc in error types that cross thread boundaries.
Convention aside: The community treats source as the single source of truth for error chaining. Don't add custom methods like inner() or cause(). Stick to source. Tools know how to use it. Custom methods fragment the ecosystem.
The compiler enforces the contract. If Debug is missing, the error type is incomplete. Fix the bounds, not the call site.
When to use manual impl versus alternatives
Choosing how to implement errors depends on your context. Use the right tool for the boundary you're crossing.
Use manual impl Error when you have a single error type, need zero dependencies, or are working in a no-std environment where crates are unavailable.
Use thiserror when your error type has multiple variants, requires chaining, or you want to derive Display and Error simultaneously with less boilerplate.
Use anyhow when you are at the top level of a binary and want to propagate errors quickly without defining custom error enums.
Use Box<dyn Error> when a function can fail in many ways and you want to return a trait object instead of a concrete enum.
Pick the tool that matches your boundary. Crates for apps, manual for constraints, enums for clarity.