Skip to content
Learn Rust
Theme

Errors in real apps: anyhow and thiserror

Errors showed the by-hand version: an enum, impl Display, impl Error, impl From. Two crates help with this. thiserror generates that code from attributes. Its docs say it “deliberately does not appear in your public API”, so a library can use it without its callers noticing. anyhow is, in its docs’ words, an error type “for easy idiomatic error handling in Rust applications”, and it adds context to errors (a message wrapped around the original error), like wrapping an exception with a cause in Java or C#, or fmt.Errorf("...: %w") in Go.

RememberA rule of thumb: a library defines its own error types (thiserror writes the boilerplate). An application returns anyhow::Result and adds .context(...) as errors travel up.

Project layout
app-errors/
├── src/
│   └── main.rs                 the program on this page
├── Cargo.lock                  created by Cargo
└── Cargo.toml                  dependencies go here
RustCargo.toml
[package]
name = "app-errors"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.104"
thiserror = "2.0.21"
Rustsrc/main.rs
use anyhow::Context;
use std::fs;
use thiserror::Error;
// Library-style error: callers can match on each case.
#[derive(Debug, Error)]
enum ConfigError {
#[error("config file not found: {0}")]
Missing(String),
#[error("port must be a number, got {0:?}")]
BadPort(String),
}
fn read_config(path: &str) -> Result<String, ConfigError> {
fs::read_to_string(path).map_err(|_| ConfigError::Missing(path.to_string()))
}
fn parse_port(text: &str) -> Result<u16, ConfigError> {
let text = text.trim();
text.parse()
.map_err(|_| ConfigError::BadPort(text.to_string()))
}
// Application code: anyhow adds context, like Go's fmt.Errorf("...: %w", err).
fn load_port(path: &str) -> anyhow::Result<u16> {
let text = read_config(path).context("loading settings")?;
let port = parse_port(&text).with_context(|| format!("reading {path}"))?;
Ok(port)
}
fn main() {
fs::write("good.conf", "8080\n").unwrap();
fs::write("bad.conf", "eighty\n").unwrap();
for path in ["good.conf", "bad.conf", "missing.conf"] {
match load_port(path) {
Ok(port) => println!("{path}: port {port}"),
Err(e) => println!("{path}: {e:#}"), // {:#} prints the whole chain
}
}
// Look for a specific error type held by this anyhow::Error (downcast_ref).
if let Err(e) = load_port("bad.conf") {
if let Some(ConfigError::BadPort(value)) = e.downcast_ref::<ConfigError>() {
println!("the bad value was {value:?}");
}
}
}
$ cargo run
good.conf: port 8080
bad.conf: reading bad.conf: port must be a number, got "eighty"
missing.conf: loading settings: config file not found: missing.conf
the bad value was "eighty"
Gomain.go
package main
import (
"errors"
"fmt"
"os"
"strconv"
"strings"
)
type BadPortError struct{ Value string }
func (e *BadPortError) Error() string { return fmt.Sprintf("port must be a number, got %q", e.Value) }
func readConfig(path string) (string, error) {
b, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("config file not found: %s", path)
}
return string(b), nil
}
func parsePort(text string) (int, error) {
text = strings.TrimSpace(text)
n, err := strconv.Atoi(text)
if err != nil {
return 0, &BadPortError{Value: text}
}
return n, nil
}
func loadPort(path string) (int, error) {
text, err := readConfig(path)
if err != nil {
return 0, fmt.Errorf("loading settings: %w", err)
}
port, err := parsePort(text)
if err != nil {
return 0, fmt.Errorf("reading %s: %w", path, err)
}
return port, nil
}
func main() {
os.WriteFile("good.conf", []byte("8080\n"), 0o644)
os.WriteFile("bad.conf", []byte("eighty\n"), 0o644)
for _, path := range []string{"good.conf", "bad.conf", "missing.conf"} {
if port, err := loadPort(path); err != nil {
fmt.Printf("%s: %v\n", path, err)
} else {
fmt.Printf("%s: port %d\n", path, port)
}
}
_, err := loadPort("bad.conf")
var bad *BadPortError
if errors.As(err, &bad) {
fmt.Printf("the bad value was %q\n", bad.Value)
}
}
$ go run .
good.conf: port 8080
bad.conf: reading bad.conf: port must be a number, got "eighty"
missing.conf: loading settings: config file not found: missing.conf
the bad value was "eighty"
  • thiserror writes the boring parts of an error enum for you: the Display message (#[error("...")]) and the conversions (#[from], which also makes the field the error’s source).
  • anyhow::Result can hold any error type that implements std::error::Error and is Send + Sync + 'static (Send and Sync explains those bounds), which is the role of Exception in Java or C#, or Go’s error interface. The docs say it works a lot like Box<dyn Error> (the lesson’s catch-all). ? converts into it.
  • .context("...") wraps an error with a message, much like %w. Print with {:#} to see the whole chain on one line: the output above matches the Go program’s.
  • downcast_ref looks for a specific error type inside an anyhow::Error, a bit like errors.As. The docs say that after you add context of type C to an error of type E, you can still downcast to either, so adding context does not break a downcast that worked before.

In this program read_config throws the io::Error away (map_err(|_| ...)), keeping only the path. The Go version does the same, because its readConfig formats a new error instead of using %w. The first task below keeps the cause.

I ran a small program for each row in the image behind this page (Java 25, Kotlin 2.4, .NET 10, tsc with --target es2022, Python 3.13, gcc 14, Go 1.27). Each one made a call fail on purpose (parsing "eighty", or fopen on a missing file) and then wrapped or reported the failure with a message.

Language Usual choice Wrapping with a cause
Go error values, errors.Is and errors.As fmt.Errorf("...: %w", err) (the Go program above)
Python exceptions raise ... from e, read back as __cause__
TypeScript throw new Error(msg, { cause }), compiled here with --target es2022
Java exceptions new RuntimeException(msg, cause), read back with getCause()
Kotlin exceptions, and Result through runCatching the same constructor and cause property as Java
C# exceptions new Exception(msg, inner), read back as InnerException
C return codes and errno the run printed only errno=2 and its text, so any context message is built by the caller
C++ exceptions, or std::expected in C++23 std::throw_with_nested and std::rethrow_if_nested; std::expected compiled with -std=c++23

I ran all three in the image, as separate programs that replace src/main.rs.

  1. Keep the cause. Give the enum a #[from] variant for each source error, so that ? converts for you, and wrap the result with with_context. In the output, the context comes first, then the enum’s message, then the original error’s text. The downcast still finds the ConfigError and its io::Error inside, and io.kind() printed NotFound:

    Rustsrc/main.rs
    use anyhow::Context;
    use std::{fs, io, num::ParseIntError};
    use thiserror::Error;
    #[derive(Debug, Error)]
    enum ConfigError {
    #[error("cannot read the file")]
    Read(#[from] io::Error),
    #[error("port is not a number")]
    Parse(#[from] ParseIntError),
    }
    fn read_port(path: &str) -> Result<u16, ConfigError> {
    let text = fs::read_to_string(path)?; // io::Error -> ConfigError::Read
    let port = text.trim().parse()?; // ParseIntError -> ConfigError::Parse
    Ok(port)
    }
    fn main() {
    fs::write("bad.conf", "eighty\n").unwrap();
    for path in ["bad.conf", "missing.conf"] {
    let e = read_port(path)
    .with_context(|| format!("loading {path}"))
    .unwrap_err();
    println!("{e:#}");
    // The ConfigError is still inside, so a caller can match on it.
    match e.downcast_ref::<ConfigError>() {
    Some(ConfigError::Read(io)) => println!(" io error kind: {:?}", io.kind()),
    Some(ConfigError::Parse(_)) => println!(" not a number"),
    None => println!(" something else"),
    }
    }
    }
    $ cargo run
    loading bad.conf: port is not a number: invalid digit found in string
      not a number
    loading missing.conf: cannot read the file: No such file or directory (os error 2)
      io error kind: NotFound
  2. Print one error three ways. {} printed only the outermost message, {:#} put the causes after it on one line, and {:?} listed them under “Caused by”. The anyhow::Error docs say {:?} also includes a backtrace if one was captured. None was captured in this run, because I had not asked for one (see the next task):

    Rustsrc/main.rs
    use anyhow::{Context, Result};
    use std::fs;
    fn load(path: &str) -> Result<String> {
    fs::read_to_string(path).with_context(|| format!("reading {path}"))
    }
    fn main() {
    let e = load("missing.conf").unwrap_err();
    println!("{{}} : {e}");
    println!("{{:#}} : {e:#}");
    println!("{{:?}} :\n{e:?}");
    println!("links in the chain: {}", e.chain().count());
    println!("root cause: {}", e.root_cause());
    }
    $ cargo run
    {}   : reading missing.conf
    {:#}  : reading missing.conf: No such file or directory (os error 2)
    {:?}  :
    reading missing.conf
    
    Caused by:
        No such file or directory (os error 2)
    links in the chain: 2
    root cause: No such file or directory (os error 2)
  3. Return the error from main. With fn main() -> Result<()>, an Err ends the program with Error: and the {:?} form, and exit code 1 (the echo shows it). This is the lesson’s main can return a Result with anyhow as the error type:

    Rustsrc/main.rs
    use anyhow::{Context, Result};
    fn main() -> Result<()> {
    let port: u16 = "eighty".parse().context("reading the port")?;
    println!("port {port}");
    Ok(())
    }
    $ cargo run; echo "exit code $?"
    Error: reading the port
    
    Caused by:
        invalid digit found in string
    exit code 1

    The lesson notes that RUST_BACKTRACE=1 adds nothing to a Box<dyn Error> returned from main. With anyhow it can: the first command counts the lines containing “Stack backtrace” in the output with the variable set, and the second is the control, the same run without it (a debug build, so I print the counts and not the frames):

    terminal
    $ RUST_BACKTRACE=1 cargo run 2>&1 | grep -c "Stack backtrace"
    1
    $ cargo run 2>&1 | grep -c "Stack backtrace"
    0
    
Gotcha

Using ? on an error your function can't hold

Looks fine: in Go it runs fine. Rust won't compile it.

Works in Go. In Rust it's just a rule.No direct equivalent in Kotlin. Just a Rust rule.No direct equivalent in Python. Just a Rust rule.No direct equivalent in TypeScript. Just a Rust rule.Java refuses this too. Same idea, different words.No direct equivalent in C#. Just a Rust rule.No direct equivalent in C. Just a Rust rule.No direct equivalent in C++. Just a Rust rule.
Rustsrc/main.rs✗ won't compile
use std::fs;
use thiserror::Error;
#[derive(Debug, Error)]
enum ConfigError {
#[error("config file not found: {0}")]
Missing(String),
}
fn read_config(path: &str) -> Result<String, ConfigError> {
let text = fs::read_to_string(path)?;
Ok(text)
}
fn main() {
match read_config("missing.conf") {
Ok(text) => println!("{text}"),
Err(e) => println!("{e}"),
}
}
package main
import (
"fmt"
"os"
)
func readConfig(path string) (string, error) {
b, err := os.ReadFile(path)
if err != nil {
return "", err // any error value fits the error interface
}
return string(b), nil
}
func main() {
if _, err := readConfig("missing.conf"); err != nil {
fmt.Println(err)
}
}
$ go run main.go✓ runs
open missing.conf: no such file or directory
import java.io.File
import java.io.IOException
// Nothing in the signature says this can throw, or what.
fun readConfig(path: String): String = File(path).readText()
fun main() {
try {
println(readConfig("missing.conf"))
} catch (e: IOException) {
println("caught ${e.javaClass.simpleName}")
}
}
$ kotlinc Main.kt -include-runtime -d main.jar && java -jar main.jarno equivalent
caught FileNotFoundException
Pythonno equivalent

Not run here. A Python function does not declare which exceptions it raises, so nothing checks them against a signature.

TypeScriptno equivalent

Not run here. A TypeScript function cannot declare which errors it throws, so nothing checks them against a signature.

import java.nio.file.Files;
import java.nio.file.Path;
public class Main {
static class ConfigError extends Exception {
ConfigError(String message) { super(message); }
}
// The signature promises ConfigError, but readString throws IOException.
static String readConfig(String path) throws ConfigError {
return Files.readString(Path.of(path));
}
public static void main(String[] args) {
try {
System.out.println(readConfig("missing.conf"));
} catch (ConfigError e) {
System.out.println(e.getMessage());
}
}
}
$ javac Main.java && java Main✗ refuses too
Main.java:11: error: unreported exception IOException; must be caught or declared to be thrown
        return Files.readString(Path.of(path));
                               ^
1 error
C#no equivalent

Not run here. C# has no checked exceptions: a method does not declare the exceptions it throws.

Cno equivalent

Not run here. A C function reports failure through its return value or errno, and nothing converts one kind of error into another for you.

C++no equivalent

Not run here. A C++ function can throw an exception of any type; its signature does not list them.

What does the compiler say? Guess first, then open.
$ cargo buildWhat is E0277?
error[E0277]: `?` couldn't convert the error to `ConfigError`
  --> src/main.rs:11:40
   |
10 | fn read_config(path: &str) -> Result<String, ConfigError> {
   |                               --------------------------- expected `ConfigError` because of this
11 |     let text = fs::read_to_string(path)?;
   |                ------------------------^ the trait `From<std::io::Error>` is not implemented for `ConfigError`
   |                |
   |                this can't be annotated with `?` because it has type `Result<_, std::io::Error>`
   |
note: `ConfigError` needs to implement `From<std::io::Error>`
  --> src/main.rs:5:1
   |
 5 | enum ConfigError {
   | ^^^^^^^^^^^^^^^^
   = note: the question mark operation (`?`) implicitly performs a conversion on the error value using the `From` trait
What happens in Go

Go compiles and runs this. The function returns the error interface, which any error value satisfies, so return "", err type-checks.

What happens in Kotlin

Kotlin compiles and runs this, and the exception is caught as IOException. A thrown exception travels up the stack, and the signature does not name its type. So there is nothing to convert, and no rule to break.

What happens in Python

Not run here. A Python function does not declare which exceptions it raises, so nothing checks them against a signature.

What happens in TypeScript

Not run here. A TypeScript function cannot declare which errors it throws, so nothing checks them against a signature.

What happens in Java

Java refuses a version of this too: a method that declares throws ConfigError cannot let an IOException escape, and javac says unreported exception IOException; must be caught or declared to be thrown. Java's rule is about checked exceptions; Rust's is about the Result type.

What happens in C#

Not run here. C# has no checked exceptions: a method does not declare the exceptions it throws.

What happens in C

Not run here. A C function reports failure through its return value or errno, and nothing converts one kind of error into another for you.

What happens in C++

Not run here. A C++ function can throw an exception of any type; its signature does not list them.

What Rust sees

This function promises to return a ConfigError. fs::read_to_string fails with an io::Error, and Rust doesn't know how to turn one into the other.

Why Rust says no

? can convert errors, but only along a route you've written: an impl From<io::Error> for ConfigError, or a #[from] on a variant with thiserror. Or convert by hand with map_err, as the fix below and the program on this page do. A function that returns anyhow::Result accepts any error that implements std::error::Error (and is Send + Sync + 'static), so it needs no conversion.

The fix

Compiles and runs.

Rust, fixedsrc/main.rsRun ↗
use std::fs;
use thiserror::Error;
#[derive(Debug, Error)]
enum ConfigError {
#[error("config file not found: {0}")]
Missing(String),
}
fn read_config(path: &str) -> Result<String, ConfigError> {
fs::read_to_string(path).map_err(|_| ConfigError::Missing(path.to_string()))
}
fn main() {
match read_config("missing.conf") {
Ok(text) => println!("{text}"),
Err(e) => println!("{e}"),
}
}
$ cargo run
config file not found: missing.conf

The versions this page is about are anyhow 1.0.104 and thiserror 2.0.21, as pinned in Cargo.lock.