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.rsUse 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
| Practice | Why |
|---|---|
| Borrow instead of clone | Avoid unnecessary allocations |
Accept &str over &String | More flexible APIs |
thiserror for libraries | Structured, typed errors |
anyhow for applications | Ergonomic error handling |
? operator over unwrap | Graceful error propagation |
| Iterator chains | Functional, composable, efficient |
let-else for early returns | Clean guard clauses |
| Derive common traits | Less boilerplate |
| Pre-allocate collections | Fewer reallocations |
| Channels over shared state | Safer concurrency |
Summary
Idiomatic Rust comes down to:
- Borrow over clone — only own data when necessary
- Handle all errors —
?operator,thiserror,anyhow - Use the type system — enums for states, newtypes for safety
- Prefer iterators — chain, filter, map, collect
- Test thoroughly — unit tests in modules, integration tests in
tests/