Rust Best Practices
Writing Rust that compiles is only the first step. Writing Rust that is safe, efficient, and maintainable in a production codebase requires deliberate habits around error handling, API design, performance, code organisation, and tooling. This page covers the most impactful best practices across each of those areas.
Error Handling Best Practices
Rust's type system makes error handling explicit, but it is easy to fall into patterns that undermine reliability. The following practices keep error handling honest.
Never Panic in Library Code
A library that panics takes away the caller's ability to recover. Always return
Result (or Option) from fallible library functions. Reserve panic!,
unwrap(), and expect() for conditions that represent programmer errors — bugs
that should never happen in correct code — and document them clearly.
// Bad — panics on bad input, caller cannot recover
pub fn parse_port(s: &str) -> u16 {
s.parse().unwrap() // panics if s is not a valid u16
}
// Good — returns an error the caller can handle
pub fn parse_port(s: &str) -> Result<u16, std::num::ParseIntError> {
s.parse()
}Result.Use thiserror for Libraries, anyhow for Applications
thiserror generates std::error::Error implementations from a custom enum with
zero boilerplate, giving callers strongly-typed errors they can match on.
anyhow provides an opaque anyhow::Error that wraps any error with a message
chain. It is ideal for application code where you just need to propagate and display
errors, not programmatically inspect them.
// Library crate — use thiserror for typed, matchable errors
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("config file not found: {path}")]
NotFound { path: String },
#[error("failed to parse config: {0}")]
ParseError(#[from] serde_json::Error),
#[error("missing required field: {field}")]
MissingField { field: &'static str },
}
pub fn load_config(path: &str) -> Result<Config, ConfigError> {
// ...
}// Application binary — use anyhow for easy propagation and context
use anyhow::{Context, Result};
fn main() -> Result<()> {
let config = load_config("app.json")
.context("failed to load application config")?;
run_server(config)
.context("server crashed")?;
Ok(())
}thiserror. If errors are only displayed to the user or logged (in a binary), use anyhow.Add Context as Errors Propagate
A raw io::Error saying "No such file or directory" is hard to debug. Adding context
at each layer transforms it into a message chain that tells you exactly what the
program was trying to do when it failed.
// Bad — bare error with no context
fn load_users(path: &str) -> Result<Vec<User>, io::Error> {
let content = fs::read_to_string(path)?;
// ...
}
// Good — every ? site adds context
use anyhow::{Context, Result};
fn load_users(path: &str) -> Result<Vec<User>> {
let content = fs::read_to_string(path)
.with_context(|| format!("could not read user file '{}'", path))?;
serde_json::from_str(&content)
.context("user file is not valid JSON")?;
// ...
}
// Error message: "could not read user file 'users.json': No such file or directory"Never Silently Discard Errors
Assigning an error to _ or calling .ok() without inspecting the result silently
drops failures. Use if let Err(e) = ... to at least log the error, or propagate it
with ?.
// Bad — error is silently discarded
let _ = write_audit_log(&entry); // did it succeed? nobody knows
// Good — log or propagate the error
if let Err(e) = write_audit_log(&entry) {
eprintln!("audit log write failed: {}", e);
// or: return Err(e.into());
}API Design
Accept the Most General Type
Follow the principle of accepting the most general input type. Use &str instead
of &String, impl Iterator instead of Vec, impl AsRef<Path> instead of
&str for filesystem paths. This makes your API usable in more contexts without
any runtime cost.
use std::path::Path;
// Overly specific — only accepts &str paths
pub fn read_config(path: &str) -> Result<Config, ConfigError> { /* ... */ }
// Better — accepts &str, String, Path, PathBuf, and more
pub fn read_config(path: impl AsRef<Path>) -> Result<Config, ConfigError> {
let path = path.as_ref();
// ...
}Return Owned Types from Public Constructors
Public constructors should return owned values. Returning a reference from a constructor ties the lifetime of the returned value to some internal storage, which is rarely what callers want and forces awkward lifetime annotations at the call site.
// Bad — caller gets a reference tied to the builder's lifetime
pub fn build(&self) -> &Config { &self.config }
// Good — caller owns the result
pub fn build(self) -> Config { self.config }Make the Happy Path Easy
Good API design means the most common case requires the least code. Provide smart
defaults via Default, use the builder pattern for optional configuration, and
offer convenience constructors for common scenarios.
// Before — caller must specify everything
let client = HttpClient::new(
"https://api.example.com",
Duration::from_secs(30),
3,
None,
None,
);
// After — sensible defaults, only override what matters
let client = HttpClient::builder()
.base_url("https://api.example.com")
.build();Document Panics, Errors, and Unsafe Invariants
The official Rust API Guidelines specify that every public function must document:
- Panics — under what conditions the function panics
- Errors — what error variants can be returned and why
- Safety — for
unsafefunctions, what invariants the caller must uphold
Use the conventional section headings in doc comments so that rustdoc renders
them consistently.
/// Divides two integers.
///
/// # Errors
///
/// Returns `Err(DivisionError::DivideByZero)` if `divisor` is zero.
///
/// # Panics
///
/// This function does not panic.
///
/// # Examples
///
/// ```
/// assert_eq!(divide(10, 2), Ok(5));
/// ```
pub fn divide(dividend: i32, divisor: i32) -> Result<i32, DivisionError> {
if divisor == 0 {
return Err(DivisionError::DivideByZero);
}
Ok(dividend / divisor)
}Performance
Always Benchmark with --release
Debug builds include overflow checks, no inlining, and no optimisations. They can be
10–100x slower than release builds. Never draw performance conclusions from a debug
build. Use cargo bench (which always uses --release) or add --release to
any manual timing.
# Run your program with full optimisations cargo run --release # Run benchmarks (always compiled with --release) cargo bench # Run tests in release mode cargo test --release
--release.Profile Before Optimising
Optimise based on measurements, not intuition. The bottleneck is almost never where
you expect. Use cargo-flamegraph to generate a flamegraph of where CPU time is
actually spent.
# Install cargo-flamegraph cargo install flamegraph # Profile your binary and open the flamegraph cargo flamegraph --bin my_app
Avoid Unnecessary Clone and Allocation
Each heap allocation has overhead: the allocator call, the potential cache miss, and the eventual deallocation. The most common sources of unnecessary allocation in Rust:
Cloning a
StringorVecto pass to a function that only needs&stror&[T]Collecting an iterator into a
Vecwhen you only need to iterate over it onceReturning a
Stringfrom a function that could return&strtied to inputCreating a
Stringwithformat!()just to immediately pass it to a function that takes&str
Use String::with_capacity for Known-Size Strings
When you know (or can estimate) the final length of a String you are building,
pre-allocate with with_capacity to avoid repeated reallocations.
// Bad — may reallocate many times as the string grows
fn join_words(words: &[&str]) -> String {
let mut result = String::new();
for (i, word) in words.iter().enumerate() {
if i > 0 { result.push(' '); }
result.push_str(word);
}
result
}
// Good — single allocation, no reallocations
fn join_words(words: &[&str]) -> String {
let total_len: usize = words.iter().map(|w| w.len() + 1).sum();
let mut result = String::with_capacity(total_len);
for (i, word) in words.iter().enumerate() {
if i > 0 { result.push(' '); }
result.push_str(word);
}
result
}Use Box<[T]> for Fixed-Size Collections
A Vec<T> stores three words: pointer, length, and capacity. If the collection will
never grow after construction, use Box<[T]> instead — it stores only pointer and
length, and signals to readers that the size is fixed.
// Vec<T> — three words, carries unused capacity overhead let items: Vec<u32> = vec![1, 2, 3, 4, 5]; // Box<[T]> — two words, ideal for permanent fixed-size data let items: Box<[u32]> = vec![1, 2, 3, 4, 5].into_boxed_slice(); // Converting is zero-cost — no allocation or copy let v: Vec<u32> = vec![10, 20, 30]; let b: Box<[u32]> = v.into_boxed_slice();
Code Organisation
One Concept Per Module
Each module should own one coherent concept: a data type and its methods, a subsystem boundary, or a group of closely related utilities. Resist the urge to create a "utils" module that accumulates unrelated helpers — that is a symptom of unclear ownership.
Keep modules small — if a module grows beyond ~300 lines, consider splitting it
Prefer
mod foo;in a file over deep inline module nestingName the module after what it represents, not after what it does
Use pub(crate) to Minimise Public API Surface
Every public symbol is a commitment. Callers can depend on it, which makes changing
or removing it a breaking change. Use pub(crate) for items that are shared across
modules internally but should not be part of your public API.
// Internal helper — visible within this crate only
pub(crate) fn compute_checksum(data: &[u8]) -> u32 { /* ... */ }
// Public API — visible to external callers
pub fn verify(data: &[u8], expected: u32) -> bool {
compute_checksum(data) == expected
}Write Doc Tests
Doc tests are code examples inside doc comments that cargo test compiles and
runs automatically. They serve double duty: documentation for readers and
regression tests that fail loudly when the API changes.
/// Adds two numbers together.
///
/// # Examples
///
/// ```
/// use my_crate::add;
/// assert_eq!(add(2, 3), 5);
/// assert_eq!(add(-1, 1), 0);
/// ```
pub fn add(a: i32, b: i32) -> i32 {
a + b
}cargo test --doc to execute only doc tests. They run in isolation — each example is its own mini-crate — so they verify that your public API is actually usable from the outside.Keep unsafe Blocks as Small as Possible
The unsafe keyword disables some of Rust's guarantees. Keep every unsafe block
to the absolute minimum required — ideally a single line. Wrap the unsafe operation
in a safe abstraction immediately, and document exactly which invariants the
surrounding safe code upholds to make the operation sound.
// Bad — huge unsafe block mixes safe and unsafe code
unsafe {
let ptr = data.as_ptr();
let len = data.len();
println!("processing {} bytes", len); // safe — doesn't need to be here
let slice = std::slice::from_raw_parts(ptr, len); // the only unsafe part
process_raw(slice);
}
// Good — unsafe block is as small as possible
let slice = unsafe {
// SAFETY: 'data' is a valid slice; ptr and len are consistent.
std::slice::from_raw_parts(data.as_ptr(), data.len())
};
println!("processing {} bytes", slice.len()); // safe code outside unsafe block
process_raw(slice);Tooling
Run cargo clippy in CI
Clippy is Rust's official linter. It catches hundreds of common mistakes and
non-idiomatic patterns that the compiler allows but that are almost certainly wrong.
Run it in CI with -D warnings so any new warning fails the build.
# Treat all warnings as errors — use this in CI cargo clippy -- -D warnings # Also check tests and examples cargo clippy --all-targets -- -D warnings
.cargo/config.toml to your project with[target.'cfg(all())'] rustflags = ["-D", "warnings"]to make every cargo build in the repo treat warnings as errors.Use cargo fmt with Format-on-Save
cargo fmt formats your code according to the official Rust style guide. Configure
your editor to run it on save so formatting never becomes a review concern. In CI,
use cargo fmt --check to verify formatting without modifying files.
# Format all files in the workspace cargo fmt # Check formatting without writing changes (for CI) cargo fmt --check
Add cargo audit to Your CI Pipeline
cargo audit checks your Cargo.lock against the RustSec advisory database for
known security vulnerabilities in your dependencies. Run it in CI so you are alerted
when a dependency you use is compromised.
# Install once cargo install cargo-audit # Run in CI cargo audit
Recommended CI Checklist
Step | Command | Purpose |
|---|---|---|
Format check | cargo fmt --check | Consistent style across the team |
Lint | cargo clippy -- -D warnings | Catch common mistakes and non-idiomatic code |
Tests | cargo test | Verify correctness |
Doc tests | cargo test --doc | Verify examples in documentation |
Security audit | cargo audit | Detect vulnerable dependencies |
Release build | cargo build --release | Catch issues that only appear with optimisations |