Skip to main content

Idiomatic Rust: Writing Safe and Performant Code

February 18, 2026

Rust gives you memory safety, fearless concurrency, and zero-cost abstractions — but writing idiomatic Rust takes practice. These best practices will help you write clean, efficient, and maintainable Rust code.

Project Structure

Follow the Standard Layout

my-project/
├── Cargo.toml
├── Cargo.lock
├── src/
   ├── main.rs            # Binary entry point
   ├── lib.rs             # Library root (public API)
   ├── config.rs
   ├── errors.rs
   ├── models/
      ├── mod.rs
      ├── user.rs
      └── post.rs
   └── handlers/
       ├── mod.rs
       └── auth.rs
├── tests/                 # Integration tests
   └── api_test.rs
├── benches/               # Benchmarks
   └── parsing.rs
└── examples/
    └── basic_usage.rs

Use Workspaces for Multi-Crate Projects

# Cargo.toml (workspace root)
[workspace]
members = [
    "crates/core",
    "crates/api",
    "crates/cli",
]

[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
# crates/api/Cargo.toml
[dependencies]
serde.workspace = true
tokio.workspace = true
core = { path = "../core" }

Ownership and Borrowing

Prefer Borrowing Over Cloning

// Bad - unnecessary clone
fn greet(name: String) {
    println!("Hello, {name}!");
}
let name = String::from("Alice");
greet(name.clone()); // Clones just to keep ownership
println!("{name}");

// Good - borrow instead
fn greet(name: &str) {
    println!("Hello, {name}!");
}
let name = String::from("Alice");
greet(&name); // Borrows - no allocation
println!("{name}");

Accept &str Over &String

// Bad - only accepts &String
fn search(query: &String) { ... }

// Good - accepts &String, &str, and string literals
fn search(query: &str) { ... }

search("hello");                    // &str
search(&String::from("hello"));    // &String coerces to &str

Use impl Trait Parameters for Flexibility

// Bad - only accepts Vec
fn process(items: &Vec<String>) { ... }

// Good - accepts Vec, slice, array, or any iterable
fn process(items: &[String]) { ... }

// Even better for iterators
fn process(items: impl IntoIterator<Item= String>) {
    for item in items {
        println!("{item}");
    }
}

Use Cow for Flexible Ownership

use std::borrow::Cow;

fn normalize(input: &str) -> Cow<str> {
    if input.contains(' ') {
        Cow::Owned(input.replace(' ', "_")) // Allocates only when needed
    } else {
        Cow::Borrowed(input) // No allocation
    }
}

Error Handling

Use thiserror for Library Errors

use thiserror::Error;

#[derive(Error, Debug)]
pub enum AppError {
    #[error("User not found: {0}")]
    NotFound(String),

    #[error("Validation failed: {0}")]
    Validation(String),

    #[error("Database error")]
    Database(#[from] sqlx::Error),

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

Use anyhow for Application Errors

use anyhow::{Context, Result};

fn read_config(path: &str) -> Result<Config> {
    let content = std::fs::read_to_string(path)
        .with_context(|| format!("Failed to read config from {path}"))?;

    let config: Config = toml::from_str(&content)
        .context("Failed to parse config file")?;

    Ok(config)
}

Use the ? Operator, Not unwrap

// Bad - panics on error
let file = File::open("data.txt").unwrap();
let content = std::fs::read_to_string("data.txt").unwrap();

// Good - propagates errors
let file = File::open("data.txt")?;
let content = std::fs::read_to_string("data.txt")?;

// Only use unwrap/expect when you're certain it can't fail
let home = env::var("HOME").expect("HOME must be set");

Create Domain-Specific Result Types

pub type Result<T> = std::result::Result<T, AppError>;

// Clean function signatures
pub fn get_user(id: &str) -> Result<User> { ... }
pub fn create_post(data: NewPost) -> Result<Post> { ... }

Structs and Enums

Use the Builder Pattern for Complex Structs

pub struct Server {
    host: String,
    port: u16,
    max_connections: usize,
    timeout: Duration,
}

impl Server {
    pub fn builder() -> ServerBuilder {
        ServerBuilder::default()
    }
}

#[derive(Default)]
pub struct ServerBuilder {
    host: String,
    port: u16,
    max_connections: usize,
    timeout: Duration,
}

impl ServerBuilder {
    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) -> Server {
        Server {
            host: self.host,
            port: self.port,
            max_connections: self.max_connections,
            timeout: self.timeout,
        }
    }
}

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

Use Enums for State Machines

enum ConnectionState {
    Disconnected,
    Connecting { attempt: u32 },
    Connected { session_id: String },
    Error { message: String, retries: u32 },
}

impl ConnectionState {
    fn handle_event(self, event: Event) -> Self {
        match (self, event) {
            (ConnectionState::Disconnected, Event::Connect) => {
                ConnectionState::Connecting { attempt: 1 }
            }
            (ConnectionState::Connecting { attempt }, Event::Success(id)) => {
                ConnectionState::Connected { session_id: id }
            }
            (ConnectionState::Connecting { attempt }, Event::Failure(msg)) => {
                ConnectionState::Error { message: msg, retries: attempt }
            }
            (state, _) => state, // Ignore invalid transitions
        }
    }
}

Derive Common Traits

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct UserId(String);

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct User {
    pub id: UserId,
    pub name: String,
    pub email: String,
}

Pattern Matching

Match Exhaustively

// Good - compiler ensures all variants are handled
match status {
    Status::Active => handle_active(),
    Status::Inactive => handle_inactive(),
    Status::Suspended { reason } => handle_suspended(reason),
    // No wildcard _ - compiler catches new variants
}

Use if let for Single Patterns

// Verbose for a single case
match result {
    Some(value) => process(value),
    None => {},
}

// Cleaner
if let Some(value) = result {
    process(value);
}

// With else
let display = if let Some(name) = user.nickname {
    name
} else {
    &user.full_name
};

Use let-else for Early Returns

fn process_user(id: &str) -> Result<()> {
    let Some(user) = find_user(id) else {
        return Err(AppError::NotFound(id.to_string()));
    };

    let Ok(profile) = fetch_profile(&user) else {
        return Err(AppError::ProfileUnavailable);
    };

    // Continue with user and profile
    Ok(())
}

Iterators

Chain Iterator Methods

// Bad - manual loops with mutation
let mut results = Vec::new();
for item in &items {
    if item.is_active() {
        results.push(item.name.to_uppercase());
    }
}

// Good - iterator chain
let results: Vec<String> = items
    .iter()
    .filter(|item| item.is_active())
    .map(|item| item.name.to_uppercase())
    .collect();

Use collect to Transform Collections

// Into HashMap
let user_map: HashMap<String, User> = users
    .into_iter()
    .map(|u| (u.id.clone(), u))
    .collect();

// Into Result<Vec<T>> - short-circuits on first error
let parsed: Result<Vec<i32>, _> = strings
    .iter()
    .map(|s| s.parse::<i32>())
    .collect();

Concurrency

Use tokio for Async Runtime

use tokio;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let (users, posts) = tokio::join!(
        fetch_users(),
        fetch_posts(),
    );

    println!("Users: {}, Posts: {}", users?.len(), posts?.len());
    Ok(())
}

Use Arc<Mutex<T>> for Shared Mutable State

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

let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];

for _ in 0..10 {
    let counter = Arc::clone(&counter);
    handles.push(task::spawn(async move {
        let mut num = counter.lock().unwrap();
        *num += 1;
    }));
}

for handle in handles {
    handle.await?;
}

Prefer Channels Over Shared State

use tokio::sync::mpsc;

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

tokio::spawn(async move {
    tx.send("hello".to_string()).await.unwrap();
    tx.send("world".to_string()).await.unwrap();
});

while let Some(msg) = rx.recv().await {
    println!("Received: {msg}");
}

Testing

Test Module Convention

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

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

    #[test]
    fn test_add() {
        assert_eq!(add(2, 3), 5);
        assert_eq!(add(-1, 1), 0);
    }

    #[test]
    #[should_panic(expected = "overflow")]
    fn test_overflow() {
        add(i32::MAX, 1);
    }
}

Use proptest for Property-Based Testing

use proptest::prelude::*;

proptest! {
    #[test]
    fn parse_then_format_roundtrips(s in "[a-z]{1,10}") {
        let parsed = parse(&s)?;
        let formatted = format_output(&parsed);
        assert_eq!(formatted, s);
    }
}

Performance

Avoid Unnecessary Allocations

// Bad - allocates a new String
fn is_valid(input: &str) -> bool {
    let lower = input.to_lowercase(); // Allocates
    lower == "yes" || lower == "true"
}

// Good - compare without allocation
fn is_valid(input: &str) -> bool {
    input.eq_ignore_ascii_case("yes") || input.eq_ignore_ascii_case("true")
}

Pre-Allocate Collections

// Bad - grows incrementally
let mut results = Vec::new();
for item in &items {
    results.push(transform(item));
}

// Good - pre-allocate
let mut results = Vec::with_capacity(items.len());
for item in &items {
    results.push(transform(item));
}

Quick Reference

PracticeWhy
Borrow instead of cloneAvoid unnecessary allocations
Accept &str over &StringMore flexible APIs
thiserror for librariesStructured, typed errors
anyhow for applicationsErgonomic error handling
? operator over unwrapGraceful error propagation
Iterator chainsFunctional, composable, efficient
let-else for early returnsClean guard clauses
Derive common traitsLess boilerplate
Pre-allocate collectionsFewer reallocations
Channels over shared stateSafer concurrency

Summary

Idiomatic Rust comes down to:

  1. Borrow over clone — only own data when necessary
  2. Handle all errors? operator, thiserror, anyhow
  3. Use the type system — enums for states, newtypes for safety
  4. Prefer iterators — chain, filter, map, collect
  5. Test thoroughly — unit tests in modules, integration tests in tests/

Recommended Posts