Rust Best Practices
Idiomatic Rust patterns for ownership and borrowing, error handling with thiserror and anyhow, choosing between traits/generics/dyn dispatch, and async programming with Tokio, targeting production-grade Rust codebases in 2026.
Introduction / Overview
Writing idiomatic Rust means designing with ownership rather than around it, choosing the right error-handling strategy for the audience of your code (library versus application), and picking the correct dispatch mechanism (static generics versus dynamic dyn Trait) for the performance and flexibility trade-offs your problem demands. In 2026, with the 2024 edition stable and async closures shipped, the ecosystem finally offers ergonomic answers to most of the friction that historically made Rust feel harder than it needed to be.
This document assumes familiarity with the concepts in README.md (ownership, borrowing, lifetimes) and focuses on applying them correctly in real codebases: structuring errors, choosing abstractions, and writing correct, efficient async code with Tokio.
When to use this guidance: You are writing production Rust - a library crate, a CLI tool, or an async network service - and want patterns that hold up as the codebase and team grow.
Core Concepts
- Ownership-first design: Structure data flow so that a single, clear owner exists for each resource at each point in time; pass borrows (
&T,&mut T) to functions that only need temporary access. Reach for shared ownership (Rc<T>,Arc<T>) only when the domain genuinely requires multiple long-lived owners. - Fallible vs infallible operations: Rust makes failure explicit via
Result<T, E>and absence explicit viaOption<T>. There are no exceptions for recoverable errors and no implicitnull. - Static vs dynamic polymorphism: Generics with trait bounds are resolved and monomorphized at compile time (zero runtime cost, larger binaries);
dyn Traitis resolved via a vtable at runtime (smaller binaries, one indirect call, heterogeneous collections). SendandSync: Marker traits the compiler uses to guarantee thread safety. A type isSendif it can be transferred across threads;Syncif references to it can be shared across threads. Async runtimes and thread-spawning APIs are bounded by these traits, catching data races at compile time.- Async is cooperative, not preemptive:
async fncompiles to a state machine that yields at.awaitpoints. Blocking the executor thread (e.g., calling synchronous I/O inside anasync fn) stalls every other task on that thread - this is the single most common async Rust bug.
Best Practices
1. Use thiserror for Library Errors, anyhow for Application Errors
Rationale: Library callers need to pattern-match on specific failure modes to recover programmatically; application code mostly needs to log, add context, and propagate. thiserror derives std::error::Error on your own enums with zero boilerplate. anyhow::Error is a type-erased, dynamically dispatched error container ideal for main() and top-level handlers where the concrete type rarely matters.
// lib/src/error.rs - public library error type, matchable by callers
use thiserror::Error;
#[derive(Debug, Error)]
pub enum OrderError {
#[error("order {order_id} not found")]
NotFound { order_id: u64 },
#[error("insufficient inventory for sku {sku}: requested {requested}, available {available}")]
InsufficientInventory { sku: String, requested: u32, available: u32 },
#[error("database error")]
Database(#[from] sqlx::Error),
}
// bin/src/main.rs - application code, aggregates and contextualizes errors
use anyhow::{Context, Result};
async fn run() -> Result<()> {
let config = load_config("config.toml")
.context("failed to load application configuration")?;
let pool = connect_db(&config.database_url)
.await
.context("failed to establish database connection pool")?;
Ok(())
}
Why this works well in production: callers of OrderError can match on InsufficientInventory to render a specific HTTP 409, while main() never has to enumerate every possible failure mode across every dependency - it just logs the full context chain via {:#}.
2. Propagate Errors with ?, Reserve unwrap()/expect() for True Invariants
Rationale: The ? operator converts and propagates errors with a single character, keeping the happy path readable while still forcing every fallible call site to be handled or explicitly deferred.
async fn place_order(pool: &PgPool, sku: &str, qty: u32) -> Result<OrderId, OrderError> {
let available = fetch_inventory(pool, sku).await?;
if available < qty {
return Err(OrderError::InsufficientInventory {
sku: sku.to_string(),
requested: qty,
available,
});
}
let order_id = insert_order(pool, sku, qty).await?;
Ok(order_id)
}
Why this works well in production: every error path is visible in the function's return type; there is no hidden control flow, and cargo clippy flags unhandled Results (#[must_use]).
3. Choose Traits, Generics, or dyn Trait Deliberately
Rationale: These three tools solve overlapping problems with different trade-offs. Defaulting to one everywhere leads either to bloated binaries and unreadable trait-bound soup, or to unnecessary runtime indirection.
| Mechanism | Dispatch | Binary size impact | Use when |
|---|---|---|---|
Generic + trait bound (fn f<T: Trait>(x: T)) | Static (monomorphized) | Larger (one copy per type) | Hot paths, small number of concrete types, want inlining |
impl Trait (arg or return position) | Static | Larger | Simplify signatures without naming the concrete type |
dyn Trait / Box<dyn Trait> | Dynamic (vtable) | Smaller (one shared copy) | Heterogeneous collections, plugin-style APIs, reducing compile times |
trait PaymentGateway {
fn charge(&self, amount_cents: u64) -> Result<(), PaymentError>;
}
// Static dispatch: fastest, one specialized copy per gateway type used
fn checkout_static<G: PaymentGateway>(gateway: &G, amount: u64) -> Result<(), PaymentError> {
gateway.charge(amount)
}
// Dynamic dispatch: one function body, heterogeneous list of gateways
fn checkout_dynamic(gateway: &dyn PaymentGateway, amount: u64) -> Result<(), PaymentError> {
gateway.charge(amount)
}
let gateways: Vec<Box<dyn PaymentGateway>> = vec![Box::new(StripeGateway), Box::new(PaypalGateway)];
Why this works well in production: reserving dyn Trait for genuinely polymorphic call sites (payment gateway selected at runtime by config) keeps hot-path code monomorphized while avoiding combinatorial compile-time blowup elsewhere.
4. Structure Async Code Around Tokio's Task Model
Rationale: Tokio is the de facto standard async runtime in 2026 (multi-threaded work-stealing scheduler, mature ecosystem: axum, tonic, sqlx, tower). Correct use means never blocking the executor and using structured concurrency primitives instead of unmanaged tokio::spawn calls.
use tokio::task::JoinSet;
use std::time::Duration;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let mut tasks = JoinSet::new();
for shard_id in 0..4 {
tasks.spawn(async move {
process_shard(shard_id).await
});
}
while let Some(result) = tasks.join_next().await {
match result {
Ok(Ok(())) => tracing::info!("shard completed"),
Ok(Err(e)) => tracing::error!(error = %e, "shard failed"),
Err(join_err) => tracing::error!(error = %join_err, "task panicked"),
}
}
Ok(())
}
async fn process_shard(shard_id: u32) -> anyhow::Result<()> {
tokio::time::timeout(Duration::from_secs(30), do_work(shard_id)).await??;
Ok(())
}
Why this works well in production:
JoinSetgives structured concurrency - every spawned task's completion (or panic) is observed, unlike baretokio::spawncalls whoseJoinHandleis dropped and forgotten.tokio::time::timeoutbounds every external call, preventing one slow dependency from exhausting the runtime's worker threads.- Use
tokio::task::spawn_blockingfor any CPU-bound or synchronous-blocking work (e.g., calling a synchronous C library, heavy hashing) to avoid starving the async scheduler.
5. Resolving Common Borrow Checker Friction
Rationale: Most borrow-checker errors map to a small set of recurring patterns. Recognizing them lets you fix the design instead of reaching for .clone().
// Friction: iterating and mutating the same collection
let mut orders = vec![Order::new(1), Order::new(2)];
// Fails: cannot borrow `orders` as mutable while iterating an immutable borrow
// for order in &orders { orders.push(order.clone()); }
// Fix: collect indices or new values first, mutate after the immutable borrow ends
let overdue_ids: Vec<u64> = orders.iter().filter(|o| o.is_overdue()).map(|o| o.id).collect();
for id in overdue_ids {
mark_escalated(&mut orders, id);
}
// Friction: returning a reference to a local value
// fn make_greeting() -> &str { let s = String::from("hi"); &s } // fails: `s` dropped at end of scope
// Fix: return owned data, let the caller decide whether to borrow or store it
fn make_greeting() -> String {
String::from("hi")
}
// Friction: shared mutable state across async tasks
use std::sync::Arc;
use tokio::sync::Mutex;
let shared_counter = Arc::new(Mutex::new(0u64));
let counter = Arc::clone(&shared_counter);
tokio::spawn(async move {
let mut guard = counter.lock().await;
*guard += 1;
});
Why this works well in production: each pattern above reflects an actual ownership question (who mutates when, who owns the data after this function returns, who needs shared access across concurrent tasks) rather than a compiler technicality - resolving it explicitly produces more maintainable code than clone-and-hope.
Common Pitfalls & Anti-Patterns
Pitfall: Leaking anyhow::Error from a Public Library API
Problem: Callers receive an opaque, type-erased error and cannot match on failure modes, forcing them to parse error message strings.
Recommended approach: Public library functions return a crate-specific Result<T, MyError> built with thiserror. Convert to anyhow::Error only at the application boundary, if at all.
Pitfall: Blocking the Async Executor
Problem: Calling std::thread::sleep, synchronous file I/O, or a CPU-heavy loop directly inside an async fn stalls every other task scheduled on that worker thread, causing latency spikes that are hard to diagnose.
Recommended approach: Use tokio::time::sleep for delays, tokio::fs for async file I/O, and tokio::task::spawn_blocking to offload CPU-bound work to a dedicated blocking thread pool.
Pitfall: Rc<RefCell<T>> as a Default Solution to Ownership Errors
Problem: Wrapping every shared struct in Rc<RefCell<T>> (or Arc<Mutex<T>> in async code) defers ownership decisions to runtime, reintroduces the possibility of panics (RefCell borrow conflicts) or deadlocks, and often signals a design that fights the ownership model rather than embracing it.
Recommended approach: Prefer passing data by value or reference along a clear ownership hierarchy; reserve interior mutability for genuinely shared, long-lived state (e.g., a connection pool, a cache).
Pitfall: Overusing Macros for Code That Generics Handle Fine
Problem: macro_rules! and procedural macros trade compile-time safety and IDE support for terse syntax, and are harder to debug than an equivalent generic function.
Recommended approach: Reach for macros only when generics genuinely cannot express the pattern (e.g., generating repetitive trait impls across many types, or derive-style boilerplate elimination).
Testing Strategies
- Unit tests (
#[cfg(test)] mod tests) for pure business logic - no I/O, no async runtime needed for most of these. - Async tests:
#[tokio::test]spins up a runtime per test; use#[tokio::test(flavor = "multi_thread")]when testing genuinely concurrent behavior. - Mocking traits: define narrow traits for external dependencies (database, HTTP client) and provide in-memory fake implementations for tests - Rust's trait objects make this a natural fit, no separate mocking framework required for most cases.
mockallis available for generating mocks from trait definitions when hand-written fakes become repetitive. - Property-based testing:
proptestfor parsers, serializers, and any function with a clear mathematical invariant (round-trip encode/decode, sort stability). - Integration tests: place in
tests/, spin up a real (or containerized, viatestcontainers) database for end-to-end coverage of the public API surface.
#[tokio::test]
async fn insufficient_inventory_returns_typed_error() {
let pool = test_pool().await;
seed_inventory(&pool, "SKU-1", 2).await;
let result = place_order(&pool, "SKU-1", 10).await;
assert!(matches!(result, Err(OrderError::InsufficientInventory { .. })));
}
Performance, Security & Scalability Considerations
- Avoid unnecessary allocation: prefer
&stroverString,&[T]overVec<T>in function signatures when ownership is not required; useCow<str>when a function sometimes needs to allocate and sometimes doesn't. - Profile before optimizing: use
cargo flamegraphorperfto find real hot paths rather than guessing; Rust's zero-cost abstractions mean idiomatic code is usually already fast. - Supply chain security: run
cargo audit(RustSec advisories) andcargo deny checkin CI on every dependency update. Install both withcargo install --locked cargo-audit cargo-deny- this places the binaries in~/.cargo/binunder the invoking user's own account, requiring nosudoand working identically on a developer laptop, a CI runner, or an AI agent's sandbox regardless of OS (see Autonomous, Least-Privilege, Portable Execution Environments);--lockedpins the tool's own dependency versions for a reproducible, non-interactive install. - Panics as a denial-of-service vector: an unhandled
panic!in a request-handling task can, depending on the runtime setup, take down more than the single request - usecatch_unwindat task boundaries in servers, or rely on Tokio's per-task isolation (a panic in one spawned task does not crash the process, only that task). - Backpressure: use bounded channels (
tokio::sync::mpsc::channel(capacity)) rather than unbounded ones to prevent memory exhaustion under load spikes. - Horizontal scalability: async services built on Tokio +
axum/tonicscale well per-instance (high connection concurrency on modest hardware) and are naturally stateless when following the ownership-first design above, simplifying horizontal scaling behind a load balancer.
Edge Cases & Advanced Usage
- Self-referential structs: not directly expressible safely; use arenas (
typed-arena), indices instead of pointers, or thePin/ouroboroscrate for the rare cases that truly need them. - Cancellation safety in async code: a future dropped mid-
.await(e.g., due totokio::select!or a timeout) must leave shared state consistent - audit anyasync fnthat mutates shared state across an.awaitpoint for cancellation safety. - FFI boundaries: when exposing Rust to C or embedding C libraries, isolate all
unsafein a thinsyscrate and expose a safe, idiomatic wrapper crate on top - the standard two-crate FFI pattern (e.g.,openssl-sys+openssl). - Const generics: enable compile-time-sized arrays and buffers parameterized by integers (
[T; N]), useful for embedded and cryptographic code that must avoid heap allocation entirely. - Workspace-wide lint enforcement: use a workspace-level
Cargo.toml[workspace.lints]table to enforceclippy::pedanticselectively across every crate in a monorepo.
When Not to Use / Alternatives
| Dimension | Rust (idiomatic, as above) | Go equivalent | Winner |
|---|---|---|---|
| Error handling verbosity | Explicit, typed, more upfront design | Explicit but simpler (if err != nil) | Go for simplicity |
| Compile-time correctness guarantees | Strongest (ownership, Send/Sync) | Weaker (races caught at runtime by -race) | Rust |
| Iteration speed for CRUD services | Slower (borrow checker, compile times) | Faster | Go |
| Async ecosystem maturity | Strong (Tokio) but more manual reasoning | Simpler (goroutines are implicit async) | Go |
| Long-term refactor safety | Excellent (compiler catches breakage) | Good, but fewer compile-time guarantees | Rust |
Choose a garbage-collected language (Go, see ../go/bestpractices.md) when team velocity and simplicity of the concurrency model matter more than eliminating an entire class of memory bugs the team may rarely encounter in practice.
Choose Python + FastAPI (see ../python/frameworks/fastapi/bestpractices.md) for rapid iteration on APIs where request throughput is not the binding constraint.
Related Documentation
- Rust Language Overview & Ecosystem
- Go Language Overview
- Go Best Practices
- Python Language Overview
- FastAPI Best Practices
- SOLID Principles
- Clean Architecture
- Autonomous, Least-Privilege, Portable Execution Environments
Glossary
Result<T, E>: The type representing either success (Ok(T)) or failure (Err(E)); Rust's primary mechanism for recoverable errors.?operator: Syntax that propagates anErr/Noneearly from the current function, converting the error type viaFromif needed.thiserror: A derive macro crate for defining structured, matchable error enums with minimal boilerplate - intended for libraries.anyhow: A type-erased, dynamically dispatched error container with.context()chaining - intended for applications.- Monomorphization: Compile-time generation of a specialized function/struct body per concrete generic type parameter.
dyn Trait: A trait object enabling runtime polymorphism via vtable dispatch, at the cost of an indirect call and heap allocation (typically behindBox).Send/Sync: Compiler-checked marker traits guaranteeing a type is safe to move across threads (Send) or share by reference across threads (Sync).- Structured concurrency: A concurrency style (e.g., Tokio's
JoinSet) where every spawned task's lifetime and result are explicitly tracked by its parent, preventing orphaned or silently-failing tasks.
Maintenance note: When updating this document, also update last_updated and re-verify recommended crate versions (tokio, thiserror, anyhow, axum) against their current published releases on crates.io.