RustBest Practices

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.

RUST
// 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()
}
Warning
A panic in a library propagates all the way up the call stack and terminates the thread (or the whole program if the panic is in the main thread). Library authors have no idea how their code will be called — always return 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.

RUST
// 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> {
    // ...
}

RUST
// 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(())
}
Tip
A good rule of thumb: if the error type is part of your public API (in a library), use 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.

RUST
// 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 ?.

RUST
// 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.

RUST
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.

RUST
// 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.

RUST
// 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 unsafe functions, what invariants the caller must uphold

Use the conventional section headings in doc comments so that rustdoc renders them consistently.

RUST
/// 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.

RUST
# 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
Warning
A common mistake is to notice that a Rust program feels slow and blame the language — then discover the program was compiled in debug mode. Always profile and benchmark with --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.

RUST
# Install cargo-flamegraph
cargo install flamegraph

# Profile your binary and open the flamegraph
cargo flamegraph --bin my_app
Tip
Look at the widest frames in the flamegraph first — those are the real hot paths. Micro-optimising a function that accounts for 0.1% of runtime will never be measurable.
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 String or Vec to pass to a function that only needs &str or &[T]

  • Collecting an iterator into a Vec when you only need to iterate over it once

  • Returning a String from a function that could return &str tied to input

  • Creating a String with format!() 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.

RUST
// 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.

RUST
// 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 nesting

  • Name 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.

RUST
// 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.

RUST
/// 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
}
Note
Run 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.

RUST
// 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.

RUST
# Treat all warnings as errors — use this in CI
cargo clippy -- -D warnings

# Also check tests and examples
cargo clippy --all-targets -- -D warnings
Tip
Add a .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.

RUST
# 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.

RUST
# 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

Success
The practices on this page reinforce each other. Clear error types make APIs easier to document. Good documentation catches missing safety invariants. Clippy enforces idioms. And a solid CI pipeline means the entire team benefits from every practice automatically, without relying on code review to catch every violation.