When errors vanish into the void
You are debugging a production service at 2 AM. The monitoring dashboard screams red. You open the logs and see Error: 5. Great. Which component failed? A database connection? A file read? A network timeout? The log gives you a number and nothing else. You spend the next hour adding println! statements, redeploying, and hoping the error reproduces.
Rust prevents this pattern by design. The compiler forces you to acknowledge every error before the code runs. But forcing you to handle an error is not the same as logging it properly. Proper error logging requires treating errors as data, attaching context as they bubble up, and emitting structured logs at the boundaries of your system.
Errors are values, not exceptions
In many languages, errors are control-flow events. You throw an exception, the stack unwinds, and you catch it somewhere else. The error is a signal that interrupts execution.
Rust treats errors as values. An error is a struct or enum that lives in memory. You can inspect it, transform it, pass it to functions, and store it. The Result<T, E> type is a box that holds either the success value T or the error value E. You don't throw errors. You return them.
This distinction changes how you log. Because errors are values, you can attach metadata to them as they move through your code. You can wrap a low-level io::Error with a message like "Failed to read config file: /etc/app.yaml". By the time the error reaches the top level, it carries a full chain of context. You log that chain once, and you have everything you need.
The minimal pattern: Result and ?
The foundation of error handling in Rust is the Result type combined with the ? operator. The ? operator extracts the success value or returns the error early. It keeps the code linear and readable.
use std::fs::File;
use std::io;
/// Reads the entire contents of a file into a String.
/// Returns an io::Error if the file cannot be opened or read.
fn read_file(path: &str) -> Result<String, io::Error> {
// File::open returns Result<File, io::Error>.
// The ? operator extracts the File on success.
// If it fails, ? returns the error immediately from this function.
let mut file = File::open(path)?;
let mut contents = String::new();
// read_to_string returns Result<usize, io::Error>.
// ? propagates any read error up to the caller.
file.read_to_string(&mut contents)?;
Ok(contents)
}
fn main() {
// The caller decides what to do with the error.
match read_file("data.txt") {
Ok(content) => println!("File content: {content}"),
Err(e) => eprintln!("Failed to read file: {e}"),
}
}
The ? operator does more than return. It performs a type conversion. If the function returns Result<T, MyError> and you call a function returning Result<T, io::Error>, the ? automatically converts io::Error to MyError using the From trait. If that conversion doesn't exist, the compiler rejects the code with E0277 (trait bound not satisfied). This mechanism lets you build error hierarchies without manual wrapping.
Convention aside: The community prefers ? over explicit match or if let for error propagation. Writing match everywhere adds noise and hides the control flow. Use ? to propagate. Use match only when you need to handle the error differently based on its type.
Adding context so errors make sense
Raw errors from the standard library are often too generic. io::Error tells you "No such file", but it doesn't tell you which file. If you have ten file reads in a function, a bare io::Error gives you no clue which one failed.
You need to attach context. The anyhow crate provides the Context trait, which adds methods like .context() and .with_context() to any error. These methods wrap the error in a new error that includes your message. The original error is preserved in a chain, so you get both the high-level context and the low-level detail.
use anyhow::{Context, Result};
use std::fs;
/// Loads configuration from a file and parses it.
/// Uses anyhow::Result for minimal boilerplate in application code.
fn load_config(path: &str) -> Result<String> {
// read_to_string returns Result<String, io::Error>.
// with_context wraps the error with a dynamic message.
// The closure is called only if an error occurs, avoiding allocation on success.
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {path}"))?;
Ok(content)
}
fn main() -> Result<()> {
match load_config("config.yaml") {
Ok(content) => println!("Config loaded: {content}"),
Err(e) => {
// anyhow prints the full error chain with context.
eprintln!("Error: {e:?}");
Ok(())
}
}
}
The output of this code includes the context and the source error:
Error: Failed to read config file: config.yaml
Caused by:
No such file or directory (os error 2)
Context is king. An error without context is just noise. Always wrap errors at the point where you know what operation is happening. Don't wait until the top level to add context. By then, you've lost the stack trace information that tells you where the error originated.
Convention aside: The community convention is to use anyhow in binary crates and thiserror in library crates. anyhow minimizes boilerplate for the final error handler. thiserror lets library authors define precise error types for consumers. Mixing them up leads to friction: libraries using anyhow force consumers to depend on anyhow, and binaries using thiserror drown in boilerplate.
Structured logging for observability
Printing errors to stderr with eprintln! works for scripts. It fails for services. Services need structured logs that can be parsed, filtered, and correlated. The tracing crate is the standard for structured logging in Rust. It provides macros like error!, warn!, and info! that emit events with fields.
Structured logs allow you to attach metadata to the log event. You can log the error value, the user ID, the request ID, and any other context. Log aggregators can index these fields and let you query them. You can filter logs by severity or by field values.
use anyhow::Result;
use tracing::error;
/// Runs the application logic.
/// Logs errors with structured fields for observability.
fn run() -> Result<()> {
// Simulate a failure.
Err(anyhow::anyhow!("Database connection timeout"))
}
fn main() {
// Initialize tracing with a subscriber.
// In production, use a subscriber that outputs JSON or sends to a log aggregator.
tracing_subscriber::fmt::init();
if let Err(e) = run() {
// Log the error with structured fields.
// The error = %e syntax formats the error using Display.
// The error = ?e syntax would use Debug.
error!(error = %e, "Application failed to start");
std::process::exit(1);
}
}
The output includes the error message and the fields:
ERROR app: Application failed to start error="Database connection timeout"
Log at the boundary. If you log deep in the stack, you'll drown in duplicates when the caller retries. Log when you decide what to do with the error. If you catch an error and retry, don't log it as a failure. Log the retry attempt. If you catch an error and return it, don't log it. Let the caller log it.
Convention aside: Use tracing for new projects. The log crate is a legacy facade that lacks spans and structured fields. tracing supersedes log and provides better performance and observability. If you must support log for compatibility, use tracing-log to bridge the two.
Pitfalls and compiler signals
Errors in Rust are easy to misuse. The compiler catches many mistakes, but some patterns lead to runtime pain.
Swallowing errors with unwrap()
Calling .unwrap() on a Result panics if the value is an error. This is useful for tests or prototyping. It is dangerous in production code. A panic crashes the thread. In a server, this can drop connections and leave resources in an inconsistent state.
If you use unwrap() and the error occurs, you get a panic message with the error value. You lose the call stack unless you enable RUST_BACKTRACE=1. Even then, the panic message is less informative than a logged error chain.
Use expect() instead of unwrap() in cases where you truly believe the error is impossible. expect() takes a message that explains why the error shouldn't happen. If it does, the message helps you debug.
// BAD: No context if this panics.
let value = result.unwrap();
// BETTER: Explains the invariant.
let value = result.expect("Config file must exist at startup");
Mismatched error types
If you return Result<T, E1> but try to return Result<T, E2>, the compiler rejects the code with E0308 (mismatched types). This happens when you have multiple error sources and forget to unify them.
The fix is to use a common error type. You can define an enum that wraps all possible errors, or use anyhow::Error in application code. The ? operator will convert individual errors to the common type via From.
Panic for recoverable errors
Using panic! for recoverable errors is a logic bug. If a file is missing, the caller might want to use a default value or prompt the user. If you panic, the caller has no chance to recover.
Use Result for errors that the caller can handle. Use panic! only for unrecoverable states, like a violated invariant or a bug in your code. The compiler helps here: if you ignore a Result, you get a warning. Treat warnings as errors with #![deny(warnings)] to catch this early.
Trust the borrow checker. It usually has a point. When the compiler rejects your error handling, it's often because you're trying to move a value out of a borrowed context or drop a resource early. Read the error message carefully. It tells you exactly what went wrong.
Decision matrix
Use Result<T, E> when the caller can handle the error or propagate it further. Use panic! only when the program is in an invalid state that no caller can fix, like a logic bug or a violated invariant. Use anyhow in binary crates where you control the error handling and want minimal boilerplate. Use thiserror in library crates where you define custom error types for consumers. Use tracing when you need structured logs with spans and fields for observability. Use eprintln! for simple scripts where adding dependencies is overkill. Use expect() when you have a strong invariant and want to document it, but prefer Result for anything that can fail in production. Use unwrap() only in tests or throwaway code.
Treat errors as data. If you can't inspect it, you can't fix it. Build error chains with context. Log at the boundaries. Let the compiler enforce safety. Your future self will thank you when the logs tell the whole story.