languages/rust/best_practices.md

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 via Option<T>. There are no exceptions for recoverable errors and no implicit null.
  • Static vs dynamic polymorphism: Generics with trait bounds are resolved and monomorphized at compile time (zero runtime cost, larger binaries); dyn Trait is resolved via a vtable at runtime (smaller binaries, one indirect call, heterogeneous collections).
  • Send and Sync: Marker traits the compiler uses to guarantee thread safety. A type is Send if it can be transferred across threads; Sync if 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 fn compiles to a state machine that yields at .await points. Blocking the executor thread (e.g., calling synchronous I/O inside an async 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.

MechanismDispatchBinary size impactUse 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)StaticLargerSimplify 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:

  • JoinSet gives structured concurrency - every spawned task's completion (or panic) is observed, unlike bare tokio::spawn calls whose JoinHandle is dropped and forgotten.
  • tokio::time::timeout bounds every external call, preventing one slow dependency from exhausting the runtime's worker threads.
  • Use tokio::task::spawn_blocking for 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. mockall is available for generating mocks from trait definitions when hand-written fakes become repetitive.
  • Property-based testing: proptest for 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, via testcontainers) 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 &str over String, &[T] over Vec<T> in function signatures when ownership is not required; use Cow<str> when a function sometimes needs to allocate and sometimes doesn't.
  • Profile before optimizing: use cargo flamegraph or perf to 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) and cargo deny check in CI on every dependency update. Install both with cargo install --locked cargo-audit cargo-deny - this places the binaries in ~/.cargo/bin under the invoking user's own account, requiring no sudo and 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); --locked pins 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 - use catch_unwind at 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/tonic scale 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 the Pin/ouroboros crate for the rare cases that truly need them.
  • Cancellation safety in async code: a future dropped mid-.await (e.g., due to tokio::select! or a timeout) must leave shared state consistent - audit any async fn that mutates shared state across an .await point for cancellation safety.
  • FFI boundaries: when exposing Rust to C or embedding C libraries, isolate all unsafe in a thin sys crate 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 enforce clippy::pedantic selectively across every crate in a monorepo.

When Not to Use / Alternatives

DimensionRust (idiomatic, as above)Go equivalentWinner
Error handling verbosityExplicit, typed, more upfront designExplicit but simpler (if err != nil)Go for simplicity
Compile-time correctness guaranteesStrongest (ownership, Send/Sync)Weaker (races caught at runtime by -race)Rust
Iteration speed for CRUD servicesSlower (borrow checker, compile times)FasterGo
Async ecosystem maturityStrong (Tokio) but more manual reasoningSimpler (goroutines are implicit async)Go
Long-term refactor safetyExcellent (compiler catches breakage)Good, but fewer compile-time guaranteesRust

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.

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 an Err/None early from the current function, converting the error type via From if 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 behind Box).
  • 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.