Best Practices

Want to write idiomatic Rust? This guide presents best practices for production Rust code across all major domains.

Ownership Best Practices

Prefer Borrowing Over Moving

Good:

fn calculate_length(s: &String) -> usize {
    s.len()
}

let s = String::from("hello");
let len = calculate_length(&s);
// s still valid

Why: Borrowing allows continued use of data. Move only when transferring ownership.

When to move: When function takes ownership for transformation or storage.


Use References to Avoid Cloning

Avoid:

fn process(data: Vec<i32>) {
    // Clones on every call
}

let data = vec![1, 2, 3];
process(data.clone());
process(data.clone());

Prefer:

fn process(data: &[i32]) {
    // Borrows, no cloning
}

let data = vec![1, 2, 3];
process(&data);
process(&data);

Why: Cloning is expensive. Borrow when you don’t need ownership.


Leverage Lifetime Elision

Verbose:

fn first_word<'a>(s: &'a str) -> &'a str {
    // ...
}

Preferred:

fn first_word(s: &str) -> &str {
    // Lifetime elided
}

Why: Compiler infers lifetimes in simple cases. Don’t annotate unless necessary.


Design APIs Around Ownership

Poor API:

fn process(data: Vec<i32>) -> Vec<i32> {
    // Forces caller to give up ownership
}

Better API:

fn process(data: &[i32]) -> Vec<i32> {
    // Borrows input, returns new output
}

// Or for in-place modification:
fn process_in_place(data: &mut Vec<i32>) {
    // Modifies in place
}

Why: Flexible APIs let callers choose whether to move, borrow, or clone.


Error Handling

Use Result for Recoverable Errors

Good:

fn read_config(path: &str) -> Result<Config, std::io::Error> {
    let contents = std::fs::read_to_string(path)?;
    Ok(parse_config(&contents))
}

Why: Caller can handle errors appropriately. Panicking denies them choice.


Provide Context with Error Types

Avoid:

fn load_config() -> Result<Config, String> {
    Err("failed".to_string())  // What failed? Where?
}

Prefer:

use thiserror::Error;

#[derive(Error, Debug)]
enum ConfigError {
    #[error("config file not found at {path}")]
    NotFound { path: String },

    #[error("invalid config: {reason}")]
    Invalid { reason: String },

    #[error(transparent)]
    Io(#[from] std::io::Error),
}

fn load_config() -> Result<Config, ConfigError> {
    // Rich error context
}

Why: Specific errors help users diagnose problems. String errors are opaque.


Avoid unwrap() in Library Code

Avoid in libraries:

pub fn parse_port(s: &str) -> u16 {
    s.parse().unwrap()  // Panic in library!
}

Prefer:

pub fn parse_port(s: &str) -> Result<u16, ParseIntError> {
    s.parse()
}

Why: Libraries shouldn’t panic. Let callers decide how to handle errors.

When unwrap() is okay: Tests, examples, prototypes, when panic is intentional.


Document Error Conditions

/// Parses configuration file.
///
/// # Errors
///
/// Returns `ConfigError::NotFound` if file doesn't exist.
/// Returns `ConfigError::Invalid` if format is wrong.
pub fn load_config(path: &str) -> Result<Config, ConfigError> {
    // ...
}

Why: Callers need to know what errors to expect.


Type Design

Make Invalid States Unrepresentable

Avoid:

struct User {
    username: String,
    email: Option<String>,  // Can be None even for active users
    active: bool,
}

Prefer:

enum UserState {
    Active {
        username: String,
        email: String,  // Required for active users
    },
    Inactive {
        username: String,
    },
}

Why: Type system prevents impossible states. No need to check invariants.


Use Newtypes for Type Safety

Avoid:

fn charge_customer(amount: f64, customer_id: i64, order_id: i64) {
    // Easy to swap customer_id and order_id
}

Prefer:

struct CustomerId(i64);
struct OrderId(i64);
struct Amount(f64);

fn charge_customer(amount: Amount, customer_id: CustomerId, order_id: OrderId) {
    // Type system prevents mixing IDs
}

Why: Prevents bugs from mixing similar types.


Leverage the Type System

Avoid runtime checks:

struct Point {
    x: f64,
    y: f64,
}

fn distance_from_origin(p: &Point) -> Result<f64, String> {
    if p.x.is_nan() || p.y.is_nan() {
        return Err("NaN coordinates".to_string());
    }
    Ok((p.x.powi(2) + p.y.powi(2)).sqrt())
}

Prefer compile-time guarantees:

struct ValidCoordinate(f64);

impl ValidCoordinate {
    fn new(value: f64) -> Result<Self, String> {
        if value.is_nan() {
            Err("NaN not allowed".to_string())
        } else {
            Ok(ValidCoordinate(value))
        }
    }
}

struct Point {
    x: ValidCoordinate,
    y: ValidCoordinate,
}

fn distance_from_origin(p: &Point) -> f64 {
    // No need to check - type guarantees validity
    (p.x.0.powi(2) + p.y.0.powi(2)).sqrt()
}

Why: Compile-time checks are cheaper and more reliable than runtime checks.


Avoid Primitive Obsession

Avoid:

fn send_email(address: String, subject: String, body: String) {
    // Easy to mix up parameters
}

Prefer:

struct EmailAddress(String);
struct Subject(String);
struct Body(String);

fn send_email(address: EmailAddress, subject: Subject, body: Body) {
    // Type system prevents mistakes
}

Why: Types document intent and prevent errors.


Concurrency

Prefer Message Passing to Shared State

Good (message passing):

use std::sync::mpsc;
use std::thread;

let (tx, rx) = mpsc::channel();

thread::spawn(move || {
    tx.send("result").unwrap();
});

let result = rx.recv().unwrap();

When shared state necessary (use Arc):

use std::sync::{Arc, Mutex};

let counter = Arc::new(Mutex::new(0));
// Share counter across threads

Why: Message passing is easier to reason about. Shared state requires careful synchronization.


Use Arc<Mutex> When Sharing Is Necessary

Correct pattern:

use std::sync::{Arc, Mutex};
use std::thread;

let data = Arc::new(Mutex::new(vec![]));

let mut handles = vec![];
for i in 0..10 {
    let data = Arc::clone(&data);
    let handle = thread::spawn(move || {
        let mut data = data.lock().unwrap();
        data.push(i);
    });
    handles.push(handle);
}

for handle in handles {
    handle.join().unwrap();
}

Why: Arc for shared ownership, Mutex for safe mutation.


Leverage Send and Sync Bounds

fn spawn_processing<T: Send + 'static>(data: T) {
    std::thread::spawn(move || {
        // Process data in thread
    });
}

Why: Compiler ensures thread safety. Send means can transfer between threads.


Avoid Deadlocks with Lock Ordering

Deadlock risk:

let mut lock1 = mutex1.lock().unwrap();
let mut lock2 = mutex2.lock().unwrap();  // Thread B might lock in opposite order

Safe:

// Always lock in same order across all threads
let (lower, higher) = if id1 < id2 {
    (id1, id2)
} else {
    (id2, id1)
};

let mut lock1 = get_mutex(lower).lock().unwrap();
let mut lock2 = get_mutex(higher).lock().unwrap();

Why: Consistent lock ordering prevents deadlocks.


Async/Await

Choose Runtime Carefully

Tokio: Most popular, rich ecosystem, multi-threaded by default

#[tokio::main]
async fn main() {
    // Tokio runtime
}

async-std: Standard library-like API, simpler for beginners

#[async_std::main]
async fn main() {
    // async-std runtime
}

Why: Runtime is fundamental choice affecting whole project.


Don’t Block the Async Executor

Avoid:

async fn bad_async() {
    std::thread::sleep(Duration::from_secs(1));  // Blocks executor!
}

Prefer:

async fn good_async() {
    tokio::time::sleep(Duration::from_secs(1)).await;  // Yields to executor
}

Why: Blocking executor thread prevents other tasks from running.


Use Async for I/O-Bound, Not CPU-Bound

Good use (I/O-bound):

async fn fetch_many_urls(urls: Vec<String>) {
    let futures: Vec<_> = urls.into_iter()
        .map(|url| reqwest::get(url))
        .collect();

    let results = futures::future::join_all(futures).await;
}

Wrong use (CPU-bound):

async fn compute_intensive() {
    // Heavy computation blocks executor
    for i in 0..1_000_000_000 {
        // ...
    }
}

For CPU-bound: Use tokio::task::spawn_blocking or threads.


Stream Processing Patterns

use tokio_stream::StreamExt;

async fn process_stream() {
    let mut stream = get_stream();

    while let Some(item) = stream.next().await {
        process_item(item).await;
    }
}

Why: Streams handle asynchronous sequences elegantly.


Performance

Measure Before Optimizing

use criterion::{black_box, criterion_group, criterion_main, Criterion};

fn fibonacci(n: u64) -> u64 {
    // Implementation
}

fn criterion_benchmark(c: &mut Criterion) {
    c.bench_function("fib 20", |b| b.iter(|| fibonacci(black_box(20))));
}

criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);

Why: Premature optimization wastes time. Measure to find real bottlenecks.


Use Iterators Over Loops

Avoid:

let mut sum = 0;
for i in 0..numbers.len() {
    sum += numbers[i];
}

Prefer:

let sum: i32 = numbers.iter().sum();

Why: Iterators are zero-cost abstractions and more expressive.


Avoid Unnecessary Allocations

Avoid:

fn format_message(name: &str) -> String {
    let mut msg = String::new();
    msg.push_str("Hello, ");
    msg.push_str(name);
    msg.push_str("!");
    msg
}

Prefer:

fn format_message(name: &str) -> String {
    format!("Hello, {}!", name)  // Single allocation
}

// Or reuse buffer:
fn format_message_into(name: &str, buffer: &mut String) {
    buffer.clear();
    buffer.push_str("Hello, ");
    buffer.push_str(name);
    buffer.push_str("!");
}

Why: Allocations are expensive in hot paths.


Leverage Zero-Cost Abstractions

// High-level, but compiles to same code as manual loop
let result: Vec<_> = data.iter()
    .filter(|x| **x > 0)
    .map(|x| x * 2)
    .collect();

Why: Write high-level code without performance penalty.


Code Organization

Clear Module Boundaries

Good structure:

// src/lib.rs
pub mod database;
pub mod models;
pub mod handlers;

// Each module has focused responsibility

Avoid:

// src/lib.rs
pub mod everything;  // God module with all code

Why: Clear boundaries improve maintainability and testing.


Minimal Public API Surface

Avoid:

pub struct InternalDetail { /* ... */ }  // Should be private

pub fn helper_function() { /* ... */ }  // Implementation detail

Prefer:

struct InternalDetail { /* ... */ }  // Private

fn helper_function() { /* ... */ }  // Private

pub struct PublicApi { /* ... */ }  // Public

Why: Small public API is easier to maintain. Private items can change freely.


Documentation Comments

/// Calculates the factorial of a number.
///
/// # Arguments
///
/// * `n` - A non-negative integer
///
/// # Examples
///
/// ```
/// use my_crate::factorial;
///
/// assert_eq!(factorial(5), 120);
/// ```
///
/// # Panics
///
/// Panics if `n` is greater than 20 (overflow).
pub fn factorial(n: u64) -> u64 {
    // Implementation
}

Why: Good documentation helps users and validates examples.


Consistent Naming Conventions

  • Modules: snake_case
  • Types: PascalCase
  • Functions: snake_case
  • Constants: SCREAMING_SNAKE_CASE
  • Lifetimes: 'a, 'b, 'c (short, lowercase)
  • Generic types: T, U, V (short) or descriptive (Key, Value)

Why: Consistency improves readability.


Testing

Write Tests as You Code

pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_add() {
        assert_eq!(add(2, 2), 4);
    }

    #[test]
    fn test_add_negative() {
        assert_eq!(add(-1, 1), 0);
    }
}

Why: Tests written alongside code are more thorough and up-to-date.


Test Edge Cases

#[cfg(test)]
mod tests {
    #[test]
    fn test_empty_input() {
        assert_eq!(process(&[]), vec![]);
    }

    #[test]
    fn test_single_element() {
        assert_eq!(process(&[1]), vec![2]);
    }

    #[test]
    fn test_max_value() {
        assert_eq!(process(&[i32::MAX]), vec![i32::MAX]);
    }
}

Why: Edge cases reveal bugs.


Use Property-Based Testing

use proptest::prelude::*;

proptest! {
    #[test]
    fn test_reverse_twice(ref v in prop::collection::vec(any::<i32>(), 0..100)) {
        assert_eq!(v, &reverse(&reverse(v)));
    }
}

Why: Finds bugs human-written tests miss.


Tooling

Use rustfmt

cargo fmt  # Format entire project

Configuration (.rustfmt.toml):

max_width = 100
tab_spaces = 4

Why: Consistent formatting across project.


Run Clippy

cargo clippy  # Find common mistakes
cargo clippy --fix  # Apply automatic fixes

Why: Catches mistakes and suggests idiomatic patterns.


Generate Documentation

cargo doc --open  # Build and view docs

Why: Documentation is first-class in Rust. Use it.


Rust Idioms

Builder Pattern for Optional Parameters

pub struct Config {
    host: String,
    port: u16,
    timeout: Duration,
}

impl Config {
    pub fn builder() -> ConfigBuilder {
        ConfigBuilder::default()
    }
}

pub struct ConfigBuilder {
    host: String,
    port: u16,
    timeout: Duration,
}

impl ConfigBuilder {
    pub fn host(mut self, host: impl Into<String>) -> Self {
        self.host = host.into();
        self
    }

    pub fn port(mut self, port: u16) -> Self {
        self.port = port;
        self
    }

    pub fn build(self) -> Config {
        Config {
            host: self.host,
            port: self.port,
            timeout: self.timeout,
        }
    }
}

// Usage:
let config = Config::builder()
    .host("localhost")
    .port(8080)
    .build();

Why: Flexible, readable construction of complex types.


Extension Traits for Convenience

pub trait StringExt {
    fn truncate_ellipsis(&self, max_len: usize) -> String;
}

impl StringExt for str {
    fn truncate_ellipsis(&self, max_len: usize) -> String {
        if self.len() <= max_len {
            self.to_string()
        } else {
            format!("{}...", &self[..max_len - 3])
        }
    }
}

// Usage:
let truncated = "Long string".truncate_ellipsis(7);

Why: Extends types you don’t own with domain-specific methods.


FromStr for Parsing

use std::str::FromStr;

struct EmailAddress(String);

impl FromStr for EmailAddress {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        if s.contains('@') {
            Ok(EmailAddress(s.to_string()))
        } else {
            Err("Invalid email".to_string())
        }
    }
}

// Usage:
let email: EmailAddress = "user@example.com".parse()?;

Why: Standardized parsing interface.


Related Content


Follow these best practices to write idiomatic, maintainable Rust code!

Last updated