<< Home

Chapter 7: Error Handling

Rust treats errors as a core part of program design. Some failures should immediately stop execution because continuing would be unsafe or meaningless. Others can be anticipated and handled in code so that the program can continue gracefully. In this chapter you will learn the difference between panicking and recoverable errors, how to use Result and the ? operator, how crates such as thiserror and anyhow improve ergonomics, how to design good error types, and how to record context using tracing.

Panic vs recoverable errors

A panic stops the current thread immediately and unwinds the stack. It is for conditions that should never happen in production or that make it impossible to proceed safely. Recoverable errors are expected possibilities such as missing files or network timeouts; you represent these cases with Result<T, E> and decide what to do.

panic! for unrecoverable conditions

Use panic! if the program has entered an invalid state and continuing would be incorrect. Typical examples include violated invariants or corrupted data structures.

fn get_slice_prefix(data: &[u8], n: usize) -> &[u8] {
  if n > data.len() {
    panic!("prefix length {} exceeds slice length {}", n, data.len());
  }
  &data[..n]
}

In release builds you should reserve panic for truly exceptional conditions. Prefer assertions during development, and remove or gate them behind configuration when appropriate.

💡 By default Rust unwinds the stack on panic and runs destructors; you can switch to aborting at compile time for smaller binaries with [profile.release] panic = "abort".

Recoverable errors with Result

Recoverable errors are encoded in the type system. A function that can fail returns Result<T, E> where E implements std::error::Error or another error type you define.

use std::fs::File;
use std::io::{self, Read};

fn read_file(path: &str) -> Result<String, io::Error> {
  let mut s = String::new();
  let mut f = File::open(path)?;
  f.read_to_string(&mut s)?;
  Ok(s)
}

A caller can then decide how to react. For example it might retry, log and continue, ask the user for a different path, or escalate the error to a higher level.

When to choose panic vs Result

Here is a quick comparison to guide your choice. If in doubt, return Result so that callers can decide policy.

Situation Use Reason
Invalid internal state that violates invariants panic! Program logic is wrong; recovery would hide a bug
User input, file system, network, environment Result External conditions are expected to fail sometimes
Prototype or short CLI where simplicity is fine expect/unwrap Ergonomic shorthand; include a clear message with expect
Library APIs used by others Result Do not crash callers; surface errors as values

Result and the ? operator

Result<T, E> carries either a success value T or an error E. The ? operator propagates an error to the caller if the value is Err; otherwise it unwraps the Ok value. This keeps code concise while preserving explicit error flow.

Basic propagation with ?

Any function that uses ? must return a compatible Result or implement From<E> for its error type so conversions can happen automatically.

use std::fs;
use std::io;

fn size_in_bytes(path: &str) -> Result<u64, io::Error> {
  let meta = fs::metadata(path)?;
  Ok(meta.len())
}

If fs::metadata fails, the io::Error is returned to the caller immediately. Otherwise execution continues and the function returns Ok with the size.

Mapping and adding context

Sometimes you want to transform or annotate the error. Use map_err to convert types, and use crates such as anyhow or eyre for convenient context.

#[derive(Debug)]
struct ConfigError(String);

fn load_config(path: &str) -> Result<String, ConfigError> {
  std::fs::read_to_string(path).map_err(|e| ConfigError(format!("read failed: {}", e)))
}

You can also attach context strings using anyhow::Context which you will see shortly.

⚠️ Avoid unwrap in libraries. Prefer expect with a helpful message in small binaries if you choose to crash rather than attempting recovery.

thiserror and anyhow for ergonomics

Manually implementing error traits can be verbose. The thiserror crate derives boilerplate for your error enums. The anyhow crate provides a flexible anyhow::Error type for application code, plus easy context. Use thiserror in libraries to define precise error types; use anyhow in binaries where one catch-all type simplifies plumbing.

Defining library errors with thiserror

With thiserror you describe variants and their display messages declaratively. Each variant can wrap a source error which participates in error chains.

use thiserror::Error;
use std::{io, num::ParseIntError};

#[derive(Debug, Error)]
pub enum AppError {
  #[error("i/o failed: {0}")]
  Io(#[from] io::Error),

  #[error("invalid port: {0}")]
  ParsePort(#[from] ParseIntError),

  #[error("config missing key: {key}")]
  MissingKey { key: String },
}

The #[from] attribute adds automatic conversions so that ? works across APIs that return those error types.

Application errors with anyhow

anyhow is ideal for top-level binaries. It erases the concrete type and captures a backtrace when enabled, while preserving sources and context strings.

use anyhow::{Context, Result};

fn run(path: &str) -> Result<()> {
  let data = std::fs::read_to_string(path)
    .with_context(|| format!("while reading {}", path))?;
  let port: u16 = std::env::var("PORT")
    .context("PORT not set")?
    .parse()
    .context("PORT must be a number")?;
  println!("config loaded, port {}", port);
  Ok(())
}

At main you can return anyhow::Result<()> to let anyhow render a friendly message.

fn main() -> anyhow::Result<()> {
  run("config.toml")
}
💡 Enable backtraces with RUST_BACKTRACE=1. anyhow will include them when the underlying error supports it.

Designing error types

Good error design makes your APIs pleasant to use and your diagnostics useful. Prefer enums for discrete failure modes, provide conversions from lower-level errors, and include structured data so callers can act programmatically.

Use enums with meaningful variants

Enumerate the ways an operation can fail. Each variant can carry data that helps a caller decide the next step.

#[derive(Debug, thiserror::Error)]
pub enum DbError {
  #[error("connection failed: {0}")]
  Connect(#[from] std::io::Error),

  #[error("not found: {entity} {id}")]
  NotFound { entity: &'static str, id: String },

  #[error("constraint violated: {name}")]
  Constraint { name: String },
}

This communicates intent more clearly than a single string message and avoids fragile string matching.

Preserve sources and context

Errors often originate deep in the stack. Preserve the original error with a source so that debuggers and logs can display the full chain.

use thiserror::Error;

#[derive(Debug, Error)]
#[error("load user failed")]
pub struct LoadUserError {
  #[from]
  source: reqwest::Error,
}

If you are not using thiserror, implement std::error::Error::source manually and add a Display implementation that is concise and actionable.

Choose error boundaries

Let libraries expose specific error types; convert to application-level types at the boundary. Do not leak transient details such as SQL strings or file paths unless they help the user fix the problem.

⚠️ Avoid stringly typed errors that only carry text. Without structured data you will end up parsing messages which is brittle and locale dependent.

Logging with tracing

Logging and structured diagnostics complement error handling. The tracing ecosystem provides spans and events that attach context to logs. You can record fields, track execution flow, and correlate errors with spans for rich reports.

Set up a subscriber

A subscriber collects and formats events. For quick starts use tracing-subscriber with an environment filter so you can adjust verbosity without recompiling.

use tracing::{error, info, instrument};
use tracing_subscriber::{fmt, EnvFilter};

fn init_tracing() {
  tracing_subscriber::registry()
    .with(fmt::layer())
    .with(EnvFilter::from_default_env())
    .init();
}

Set RUST_LOG=info or similar to control levels at runtime. Levels include error, warn, info, debug, and trace.

Spans, events, and errors

Use #[instrument] to create spans automatically and record function arguments. Emit events at decision points, and include the error chain when failures occur.

#[instrument(skip(path))]
fn process(path: &str) -> anyhow::Result<()> {
  let data = std::fs::read_to_string(path)
    .with_context(|| format!("reading {}", path))?;
  info!(bytes = data.len(), "loaded file");
  if data.is_empty() {
    anyhow::bail!("file is empty");
  }
  Ok(())
}

fn main() -> anyhow::Result<()> {
  init_tracing();
  if let Err(e) = process("data.txt") {
    error!(error = %e, "processing failed");
  }
  Ok(())
}

Many libraries emit tracing events so your application gains visibility across dependencies. Pair this with error chains to get actionable reports.