
Rust Best Practices
- 165 installs
- 552 repo stars
- Updated August 1, 2026
- pedronauck/skills
Write idiomatic Rust for CLIs and services: ownership, errors, async, testing, crate boundaries, and performance-conscious API design.
About
Encodes idiomatic Rust development standards for reliable binaries and libraries, emphasizing memory safety, error propagation, module structure, testing discipline, and performance-conscious patterns suited to CLIs, APIs, and systems services.
- Ownership and borrowing
- Error handling idioms
- Async and concurrency
- Crate API design
- Rust testing patterns
Rust Best Practices by the numbers
- 165 all-time installs (skills.sh)
- +8 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #58 of 121 Rust skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pedronauck/skills --skill rust-best-practicesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 165 |
|---|---|
| repo stars | ★ 552 |
| Last updated | August 1, 2026 |
| Repository | pedronauck/skills ↗ |
What it does
Write idiomatic Rust for CLIs and services: ownership, errors, async, testing, crate boundaries, and performance-conscious API design.
Files
Rust Best Practices
Unified Rust guidelines covering coding style, ownership, error handling, async patterns, traits, testing, performance, linting, and documentation. Apply when writing or reviewing Rust code.
When to Apply
- Writing new Rust code or designing APIs
- Reviewing or refactoring existing Rust code
- Implementing async systems with Tokio
- Designing error hierarchies with thiserror/anyhow
- Choosing between borrowing, cloning, or ownership transfer
- Setting up tests, benchmarks, or snapshot testing
- Configuring clippy lints and workspace settings
- Optimizing Rust code for performance
Reference Guide
Load detailed guidance based on context. Read the relevant file when the topic arises:
| Topic | Reference | Load When |
|---|---|---|
| Coding Style | references/coding-style.md | Naming, imports, iterators, comments, string handling, macros |
| Error Handling | references/error-handling.md | Result, Option, ?, thiserror, anyhow, custom errors, async errors |
| Ownership & Pointers | references/ownership-and-pointers.md | Lifetimes, borrowing, smart pointers, Pin, Cow, interior mutability |
| Traits & Generics | references/traits-and-generics.md | Trait design, dispatch, GATs, sealed traits, type state pattern |
| Async & Concurrency | references/async-and-concurrency.md | Tokio, channels, streams, shutdown, runtime config, async traits |
| Sync Concurrency | references/concurrency-sync.md | Atomics, Mutex, RwLock, lock ordering, Send/Sync, memory ordering |
| Testing | references/testing.md | Unit/integration/doc tests, snapshot, proptest, mockall, benchmarks, fuzz |
| Performance | references/performance.md | Profiling, flamegraph, cloning, stack vs heap, iterators, allocation |
| Clippy & Linting | references/clippy-and-linting.md | Clippy config, key lints, workspace setup, #[expect] vs #[allow] |
| Documentation | references/documentation.md | Doc comments, rustdoc, doc lints, coverage checklist |
Quick Reference: Coding Style
- Prefer
&Tover.clone()unless ownership transfer is required - Use
&stroverString,&[T]overVec<T>in function parameters - No
get_prefix on getters:fn name()notfn get_name() - Conversion naming:
as_(cheap borrow),to_(expensive/cloning),into_(ownership transfer) - Iterator methods:
iter()/iter_mut()/into_iter() - Import ordering:
std-> external crates -> workspace crates ->super::->crate:: - Comments explain why (safety, workarounds), not what
- Use
format!over string concatenation with+ - Prefer
s.bytes()overs.chars()for ASCII-only operations - Avoid macros unless necessary; prefer functions or generics
Quick Reference: Error Handling
- Return
Result<T, E>for fallible operations; reservepanic!for unrecoverable bugs - No `unwrap()` in production. Use
expect()with descriptive message only when the value is logically guaranteed. Prefer?,if let,let...elsefor all other cases - Use
thiserrorfor library/crate errors,anyhowfor binaries only - Prefer
?operator overmatchchains for error propagation - Use
_elsevariants (ok_or_else,unwrap_or_else) to prevent eager allocation - Use
inspect_errandmap_errfor logging and transforming errors assert!at function entry for invariant checking (debug builds)
Quick Reference: Ownership & Pointers
- Small
Copytypes (<=24 bytes, all fieldsCopy, no heap) pass by value - Use
Cow<'_, T>when data may or may not need ownership - Meaningful lifetime names:
'src,'ctx,'conn— not just'a - Use
try_borrow()onRefCellto avoid panics; prefer over direct.borrow_mut() - Shadowing for transformations:
let x = x.parse()?
| Pointer | When to Use |
|---|---|
Box<T> | Single ownership, heap allocation, recursive types |
Rc<T> | Shared ownership, single-threaded |
Arc<T> | Shared ownership, multi-threaded |
Cell<T> / RefCell<T> | Interior mutability, single-threaded |
Mutex<T> / RwLock<T> | Interior mutability, multi-threaded |
Quick Reference: Traits & Generics
- Prefer generics (static dispatch) by default for zero-cost abstractions
- Use
dyn Traitonly when heterogeneous collections or plugin architectures are needed - Box at API boundaries, not internally
- Object safety: no generic methods, no
Self: Sized, methods use&self/&mut self/self - Use sealed traits to prevent external implementors
- Type state pattern encodes valid states in the type system:
struct Connection<S> { _state: PhantomData<S> }
struct Disconnected;
struct Connected;
impl Connection<Connected> { fn send(&self, data: &[u8]) { /* ... */ } }Quick Reference: Async & Concurrency
- Async for I/O-bound work, sync for CPU-bound work
- Never hold locks across
.awaitpoints — use scoped guards - Never use
std::thread::sleepin async — usetokio::time::sleep - Never spawn unboundedly — use semaphores for limits
- Ensure
Sendbounds on spawned futures - Use
JoinSetfor managing multiple concurrent tasks - Use
CancellationToken(fromtokio_util) for graceful shutdown - Instrument with
tracing+#[instrument]for async debugging
| Channel | Use Case |
|---|---|
mpsc | Multi-producer, single-consumer message passing |
broadcast | Multi-producer, multi-consumer event fan-out |
oneshot | Single value, single use (request-response) |
watch | Latest-value-only, change notification |
- Sync channels:
crossbeam::channeloverstd::sync::mpsc - Async channels:
tokio::sync::{mpsc, broadcast, oneshot, watch} - Atomics (
AtomicBool,AtomicUsize) overMutexfor primitive types - Choose memory ordering carefully:
Relaxed/Acquire/Release/SeqCst
Quick Reference: Testing
- Name tests descriptively:
process_should_return_error_when_input_empty() - One assertion per test when possible; include formatted failure messages
- Group tests in
modblocks by unit of work - Use doc tests (
///) for public API examples; run separately withcargo test --doc - Snapshot testing:
cargo insta testthencargo insta review; redact unstable fields rstestfor parameterized tests with#[case::name]labelsproptestfor property-based testing with custom strategiesmockallwith#[automock]for mocking traitscriterionfor benchmarks withiter_batchedandBenchmarkIdcargo-fuzzwithlibfuzzer_sysfor fuzz testingcargo-tarpaulinorcargo-llvm-covfor code coveragesqlx::testfor database integration tests with automatic pool injection- Use
#[should_panic]and#[ignore]attributes where appropriate
Quick Reference: Performance
- Golden rule: don't guess, measure. Always benchmark with
--release - Run
cargo clippy -- -D clippy::perffor performance-related hints - Use
cargo flamegraphorsamply(macOS) for profiling - Avoid cloning in loops; clone at the last moment only
- Pre-allocate:
Vec::with_capacity(),String::with_capacity() - Prefer iterators over manual
forloops; avoid intermediate.collect() - Stack for small types, heap for large/recursive; use
smallvecfor large const arrays - Use
Cow<'_, T>to avoid unnecessary allocation - Prefer
s.bytes()overs.chars()for ASCII-only string operations
Quick Reference: Clippy & Linting
Run regularly:
cargo clippy --all-targets --all-features --locked -- -D warnings| Lint | Catches |
|---|---|
redundant_clone | Unnecessary .clone() calls |
needless_borrow | Unnecessary & borrows |
large_enum_variant | Oversized variants (consider Box) |
needless_collect | Premature .collect() before iteration |
map_unwrap_or | .map().unwrap_or() chains |
unnecessary_wraps | Functions always returning Ok/Some |
clone_on_copy | .clone() on Copy types |
- Use
#[expect(clippy::lint)]over#[allow(...)]—expectwarns when lint no longer applies - Add justification comment on every suppression
- Set
#![warn(clippy::all)]as workspace minimum - Configure workspace lints in
Cargo.tomlwith priority levels
Quick Reference: Documentation
//comments explain why: safety invariants, workarounds, design rationale///doc comments explain what and how for all public items//!for module-level and crate-level documentation at top oflib.rs/mod.rs- Every
TODOneeds a linked issue:// TODO(#42): description - Enable
#![deny(missing_docs)]for libraries - Include
# Examples,# Errors,# Panics,# Safetysections in doc comments
| Doc Lint | Purpose |
|---|---|
missing_docs | Ensure all public items documented |
broken_intra_doc_links | Catch dead cross-references |
missing_panics_doc | Document panic conditions |
missing_errors_doc | Document error conditions |
missing_safety_doc | Document unsafe safety requirements |
Quick Reference: Data Types & Patterns
- Use newtypes for domain semantics:
struct Email(String) - Prefer slice patterns:
if let [first, .., last] = slice - Use arrays for fixed sizes; avoid
Vecwhen length is known at compile time - Shadowing for transformation:
let x = x.parse()? Cow<str>when data might need modification of borrowed datacontains()on strings is O(n*m) — avoid nested string iteration
Deprecated to Modern Migration
| Deprecated | Better | Since |
|---|---|---|
lazy_static! | std::sync::OnceLock | Rust 1.70 |
once_cell::Lazy | std::sync::LazyLock | Rust 1.80 |
std::sync::mpsc | crossbeam::channel (sync) | — |
std::sync::Mutex | parking_lot::Mutex (recommended) | — |
failure / error-chain | thiserror / anyhow | — |
try!() | ? operator | Rust 2018 |
async-trait crate | Native async fn in traits (1.75+, limited) | Rust 1.75 |
Cargo.toml Essentials
Recommended dependencies:
[dependencies]
thiserror = "2"
anyhow = "1"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
tracing = "0.1"
tracing-subscriber = "0.3"
[dev-dependencies]
rstest = "0.25"
proptest = "1"
mockall = "0.13"
criterion = { version = "0.5", features = ["html_reports"] }
insta = { version = "1", features = ["yaml"] }Workspace lints (Cargo.toml):
[workspace.lints.clippy]
all = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }rustfmt.toml:
reorder_imports = true
imports_granularity = "Crate"
group_imports = "StdExternalCrate"Constraints
MUST DO
1. Use ownership and borrowing for memory safety 2. Handle all errors explicitly via Result/Option — no silent failures 3. Use thiserror for library errors, anyhow for binaries 4. Minimize unsafe code; document all unsafe blocks with safety invariants 5. Use the type system for compile-time guarantees 6. Run cargo clippy and fix all warnings 7. Use cargo fmt for consistent formatting 8. Write tests including doc tests for public APIs 9. Add /// documentation with examples for all public items 10. Use tracing for observability in async code 11. When reviewing or writing code, suggest a testing approach using the recommended tools (rstest, proptest, insta, mockall, criterion) — even if the user did not ask for tests
MUST NOT DO
1. Use unwrap() in production code 2. Create memory leaks or dangling pointers 3. Use unsafe without documented safety invariants 4. Ignore clippy warnings without #[expect(...)] and justification 5. Hold locks across .await points 6. Use std::thread::sleep in async context 7. Skip error handling or use panic! for recoverable errors 8. Use String where &str suffices; clone unnecessarily 9. Spawn tasks unboundedly without semaphore limits 10. Mix blocking and async code without spawn_blocking
Async Programming and Concurrency
Async Execution Model
Future (lazy) -> poll() -> Ready(value) | Pending
^ |
Waker <- Runtime schedules| Concept | Purpose |
|---|---|
Future | Lazy computation that may complete later |
async fn | Function returning impl Future |
await | Suspend until future completes |
Task | Spawned future running concurrently |
Runtime | Executor that polls futures |
Core rule: Async for I/O-bound work, sync for CPU-bound work.
Quick Start
[dependencies]
tokio = { version = "1", features = ["full"] }
futures = "0.3"
tokio-util = "0.7"
anyhow = "1.0"
tracing = "0.1"
tracing-subscriber = "0.3"#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt::init();
let result = fetch_data("https://api.example.com").await?;
println!("Got: {}", result);
Ok(())
}Concurrent Task Execution
JoinSet for Multiple Tasks
use tokio::task::JoinSet;
async fn fetch_all(urls: Vec<String>) -> anyhow::Result<Vec<String>> {
let mut set = JoinSet::new();
for url in urls {
set.spawn(async move { fetch_data(&url).await });
}
let mut results = Vec::new();
while let Some(res) = set.join_next().await {
match res {
Ok(Ok(data)) => results.push(data),
Ok(Err(e)) => tracing::error!("Task failed: {}", e),
Err(e) => tracing::error!("Join error: {}", e),
}
}
Ok(results)
}Concurrency-Limited Streams
use futures::stream::{self, StreamExt};
async fn fetch_with_limit(urls: Vec<String>, limit: usize) -> Vec<anyhow::Result<String>> {
stream::iter(urls)
.map(|url| async move { fetch_data(&url).await })
.buffer_unordered(limit)
.collect()
.await
}join! and try_join!
// Concurrent execution
let (r1, r2) = tokio::join!(operation1(), operation2());
// Stops on first error
let (r1, r2) = tokio::try_join!(fallible_op1(), fallible_op2())?;select! for Racing
async fn race_requests(url1: &str, url2: &str) -> anyhow::Result<String> {
tokio::select! {
result = fetch_data(url1) => result,
result = fetch_data(url2) => result,
}
}Channels
| Channel | Use Case |
|---|---|
mpsc | Multi-producer, single-consumer message passing |
broadcast | Multi-producer, multi-consumer event fan-out |
oneshot | Single value, single use (request-response) |
watch | Latest-value-only, change notification |
mpsc
let (tx, mut rx) = tokio::sync::mpsc::channel::<String>(100);
let tx2 = tx.clone();
tokio::spawn(async move { tx2.send("Hello".to_string()).await.unwrap(); });
while let Some(msg) = rx.recv().await {
println!("Got: {}", msg);
}broadcast
let (tx, _) = tokio::sync::broadcast::channel::<String>(100);
let mut rx1 = tx.subscribe();
let mut rx2 = tx.subscribe();
tx.send("Event".to_string()).unwrap();
// Both receivers get the messageoneshot
let (tx, rx) = tokio::sync::oneshot::channel::<String>();
tokio::spawn(async move { tx.send("Result".to_string()).unwrap(); });
let result = rx.await.unwrap();watch
let (tx, mut rx) = tokio::sync::watch::channel("initial".to_string());
tokio::spawn(async move {
loop {
rx.changed().await.unwrap();
println!("New value: {}", *rx.borrow());
}
});
tx.send("updated".to_string()).unwrap();Graceful Shutdown
CancellationToken (Primary)
use tokio_util::sync::CancellationToken;
async fn run_server() -> anyhow::Result<()> {
let token = CancellationToken::new();
let token_clone = token.clone();
tokio::spawn(async move {
loop {
tokio::select! {
_ = token_clone.cancelled() => {
tracing::info!("Task shutting down");
break;
}
_ = do_work() => {}
}
}
});
tokio::signal::ctrl_c().await?;
tracing::info!("Shutdown signal received");
token.cancel();
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
Ok(())
}Alternative: Broadcast Channel
let (shutdown_tx, _) = tokio::sync::broadcast::channel::<()>(1);
let mut rx = shutdown_tx.subscribe();
tokio::spawn(async move {
tokio::select! {
_ = rx.recv() => tracing::info!("Received shutdown"),
_ = async { loop { do_work().await } } => {}
}
});
tokio::signal::ctrl_c().await?;
let _ = shutdown_tx.send(());Alternative: Watch Channel
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
tokio::spawn(background_task(shutdown_rx));
tokio::signal::ctrl_c().await?;
shutdown_tx.send(true).unwrap();Async Traits
Native async fn in traits is stable since Rust 1.75 but has limitations with dyn dispatch. Use async-trait crate when trait objects are needed:
use async_trait::async_trait;
#[async_trait]
pub trait Repository {
async fn get(&self, id: &str) -> anyhow::Result<Entity>;
async fn save(&self, entity: &Entity) -> anyhow::Result<()>;
}
#[async_trait]
impl Repository for PostgresRepository {
async fn get(&self, id: &str) -> anyhow::Result<Entity> {
sqlx::query_as!(Entity, "SELECT * FROM entities WHERE id = $1", id)
.fetch_one(&self.pool)
.await
.map_err(Into::into)
}
// ...
}
// Trait object usage
async fn process(repo: &dyn Repository, id: &str) -> anyhow::Result<()> {
let entity = repo.get(id).await?;
repo.save(&entity).await
}Streams and Async Iteration
Creating Streams with async_stream
use async_stream::stream;
use futures::stream::Stream;
fn numbers_stream() -> impl Stream<Item = i32> {
stream! {
for i in 0..10 {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
yield i;
}
}
}Processing Streams
use futures::stream::StreamExt;
// Filter, map, collect
let processed: Vec<_> = numbers_stream()
.filter(|n| futures::future::ready(*n % 2 == 0))
.map(|n| n * 2)
.collect()
.await;
// Chunked processing
let mut chunks = numbers_stream().chunks(3);
while let Some(chunk) = chunks.next().await {
println!("Processing chunk: {:?}", chunk);
}
// Merge multiple streams
use futures::stream;
let merged = stream::select(numbers_stream(), numbers_stream());
merged.for_each(|n| async move { println!("Got: {}", n); }).await;Resource Management
Shared State with RwLock (Read-Heavy)
use std::sync::Arc;
use tokio::sync::RwLock;
struct Cache {
data: RwLock<std::collections::HashMap<String, String>>,
}
impl Cache {
async fn get(&self, key: &str) -> Option<String> {
self.data.read().await.get(key).cloned()
}
async fn set(&self, key: String, value: String) {
self.data.write().await.insert(key, value);
}
}Connection Pool with Semaphore
use tokio::sync::{Mutex, Semaphore, SemaphorePermit};
struct Pool {
semaphore: Semaphore,
connections: Mutex<Vec<Connection>>,
}
impl Pool {
fn new(size: usize) -> Self {
Self {
semaphore: Semaphore::new(size),
connections: Mutex::new((0..size).map(|_| Connection::new()).collect()),
}
}
async fn acquire(&self) -> PooledConnection<'_> {
let permit = self.semaphore.acquire().await.unwrap();
let conn = self.connections.lock().await.pop().unwrap();
PooledConnection { pool: self, conn: Some(conn), _permit: permit }
}
}
struct PooledConnection<'a> {
pool: &'a Pool,
conn: Option<Connection>,
_permit: SemaphorePermit<'a>,
}
impl Drop for PooledConnection<'_> {
fn drop(&mut self) {
if let Some(conn) = self.conn.take() {
let pool = self.pool;
tokio::spawn(async move {
pool.connections.lock().await.push(conn);
});
}
}
}Manual Future Implementation
use std::pin::Pin;
use std::future::Future;
use std::task::{Context, Poll};
struct DelayedValue {
value: i32,
delay: tokio::time::Sleep,
}
impl Future for DelayedValue {
type Output = i32;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
match Pin::new(&mut self.delay).poll(cx) {
Poll::Ready(_) => Poll::Ready(self.value),
Poll::Pending => Poll::Pending,
}
}
}Runtime Configuration
// Custom multi-thread runtime
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(4)
.thread_name("my-worker")
.thread_stack_size(3 * 1024 * 1024)
.enable_all()
.build()
.unwrap();
// Single-threaded runtime
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();Debugging
tokio-console
# Cargo.toml: tokio = { features = ["tracing"] }
RUSTFLAGS="--cfg tokio_unstable" cargo run
# Then: tokio-consoleTracing Instrumentation
use tracing::instrument;
#[instrument(skip(pool))]
async fn fetch_user(pool: &PgPool, id: &str) -> anyhow::Result<User> {
tracing::debug!("Fetching user");
// ...
}
// Track task spawning
let span = tracing::info_span!("worker", id = %worker_id);
tokio::spawn(async move {
// Enters span when polled
}.instrument(span));Best Practices
Do
- Use
tokio::select!for racing futures - Prefer channels over shared state when possible
- Use
JoinSetfor managing multiple tasks - Instrument with
tracingfor debugging async code - Handle cancellation via
CancellationToken - Use
spawn_blockingfor blocking operations - Use timeout for all external I/O operations
- Prefer
try_join!over manual error handling
Don't
- Never use
std::thread::sleepin async context - Never hold locks across
.awaitpoints - Never spawn unboundedly — use semaphores for limits
- Never ignore errors — propagate with
?or log - Never forget
Sendbounds on spawned futures
Clippy and Linting Discipline
Why Clippy
cargo clippy catches issues the compiler misses:
- Performance pitfalls
- Style issues and non-idiomatic Rust
- Redundant code
- Potential bugs
Always Run Clippy
Add to daily workflow and CI:
cargo clippy --all-targets --all-features --locked -- -D warnings--all-targets: checks library, tests, benches, examples--all-features: checks code for all features--locked: requires up-to-dateCargo.lock-D warnings: treats warnings as errors
Optional additions:
-- -W clippy::pedantic: stricter lints (occasional false positives)-- -W clippy::nursery: new lints under development
Important Lints
| Lint | Why | Category |
|---|---|---|
redundant_clone | Unnecessary .clone(), performance impact | nursery + perf |
needless_borrow | Redundant & borrowing | style |
map_unwrap_or | Simplifies nested Option/Result handling | pedantic |
manual_ok_or | Suggests .ok_or_else instead of match | style |
large_enum_variant | Oversized variant — suggests Boxing | perf |
unnecessary_wraps | Function always returns Some/Ok | pedantic |
clone_on_copy | .clone() on Copy types | complexity |
needless_collect | Collecting iterator when allocation not needed | nursery |
Fix Warnings, Don't Silence Them
Never use #[allow(clippy::lint)] unless: 1. The warning is understood and justified 2. The justification is documented
Use #[expect(...)] instead of #[allow(...)] — expect warns when the lint no longer applies:
// Faster matching is preferred over size efficiency
#[expect(clippy::large_enum_variant)]
enum Message {
Code(u8),
Content([u8; 1024]),
}Handling False Positives
1. Try to refactor the code to satisfy the lint 2. If refactoring is not feasible, locally override with #[expect(clippy::lint_name)] and a reason comment 3. Avoid global overrides unless it is a core crate concern
Workspace and Package Lint Configuration
Configure in Cargo.toml with priority levels. Higher priority wins on conflicts:
Package-level:
[lints.rust]
future-incompatible = "warn"
nonstandard_style = "deny"
[lints.clippy]
all = { level = "deny", priority = 10 }
redundant_clone = { level = "deny", priority = 9 }
pedantic = { level = "warn", priority = 3 }Workspace-level:
[workspace.lints.rust]
future-incompatible = "warn"
nonstandard_style = "deny"
[workspace.lints.clippy]
all = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }Minimum baseline: #![warn(clippy::all)] in every crate.
Coding Style and Idioms
Borrowing Over Cloning
Prefer &T over .clone(). Use &str over String, &[T] over Vec<T> in function parameters.
// Good: borrows
fn process(name: &str) {
println!("Hello {name}");
}
// Bad: unnecessary ownership
fn process_string(name: String) {
println!("Hello {name}");
}Clone Traps to Avoid
- Auto-cloning in loops: prefer
.cloned()or.copied()at the end of the iterator chain - Cloning large data structures like
Vec<T>orHashMap<K, V> - Cloning because of bad API design instead of adjusting lifetimes
- Cloning a reference argument — if ownership is needed, make it explicit in the function signature
When to Clone
- Immutable snapshots (need to change AND preserve the original)
- Reference-counted pointers (
Arc,Rc) - Sharing data across threads (usually
Arc) - When the underlying API requires owned data
- Caching results
Copy Trait
Small types (<=24 bytes) with all Copy fields and no heap allocations should derive Copy:
// Good: small, all fields Copy
#[derive(Debug, Copy, Clone)]
struct Point { x: f32, y: f32, z: f32 }
// Bad: String is not Copy
#[derive(Debug, Clone)]
struct BadIdea { age: i32, name: String }Enums can derive Copy when they act as tags and all payloads are Copy. Enum size equals the largest variant.
Primitive type sizes: i8/u8: 1B, i16/u16: 2B, i32/u32: 4B, i64/u64: 8B, i128/u128: 16B, f32: 4B, f64: 8B, bool: 1B, char: 4B, isize/usize: arch-dependent.
Naming Conventions
| Convention | Rule |
|---|---|
| General | snake_case (fn/var), CamelCase (type), SCREAMING_CASE (const) |
No get_ prefix | fn name() not fn get_name() |
| Iterator methods | iter() / iter_mut() / into_iter() |
| Conversions | as_ (cheap borrow &), to_ (expensive/cloning), into_ (ownership transfer) |
| Static variables | G_CONFIG prefix for static, no prefix for const |
Option and Result Pattern Matching
Use match when pattern matching against inner types:
match self {
Ok(Direction::South) => { /* ... */ },
Ok(Direction::North) => { /* ... */ },
Err(E::One) => { /* ... */ },
}Use let...else for early returns when the missing case is expected:
let Some(value) = optional else { return Err(MyError::Missing); };Use if let...else when recovery requires extra computation:
if let Some(x) = self.next() {
// computation
} else {
// computation when None
}Bad patterns to avoid:
- Converting between Result and Option manually — use
.ok(),.ok_or(),.ok_or_else() - Using
unwrap/expectoutside tests
Iterators vs For Loops
Both are idiomatic. Each excels in different contexts.
Prefer for loops when:
- Early exits needed (
break,continue,return) - Simple iteration with side effects
- Readability matters more than chaining
Prefer iterators when:
- Transforming collections or Option/Results
- Composing multiple steps elegantly
- Using
.enumerate(),.windows(),.chunks() - Combining data from multiple sources without intermediate allocation
// Iterator style
let sum: i32 = (0..=10).filter(|x| x % 2 == 0).map(|x| x + 1).sum();Anti-patterns:
- Needless
.collect()just to iterate again — pass the iterator directly - Using
into_iterwhenitersuffices (don't take ownership unnecessarily) - For summing, prefer
.sum()over.fold()—.sum()is specialized for optimization
Iterators are lazy: .iter, .map, .filter don't execute until consumed (.collect, .sum, .for_each).
String Handling
- Prefer
s.bytes()overs.chars()for ASCII-only operations - Use
Cow<str>when data might need modification of borrowed data - Use
format!over string concatenation with+ contains()on strings is O(n*m) — avoid nested string iteration
Import Ordering
Standard order, enforceable via rustfmt.toml:
1. std (core, alloc) 2. External crates (from Cargo.toml [dependencies]) 3. Workspace crates 4. super:: 5. crate::
use std::sync::Arc;
use chrono::Utc;
use uuid::Uuid;
use broker::database::PooledConnection;
use super::schema::{Context, Payload};
use crate::models::Event;rustfmt.toml config:
reorder_imports = true
imports_granularity = "Crate"
group_imports = "StdExternalCrate"As of Rust 1.88, execute cargo +nightly fmt for correct reordering.
Comments: Context, Not Clutter
Comments explain why, not what or how. Well-written code with expressive types speaks for itself.
Good comments:
// SAFETY: pointer is guaranteed non-null and aligned by caller
unsafe { std::ptr::copy_nonoverlapping(src, dst, len); }
// PERF: Root store per subgraph caused high TLS startup latency on MacOS
// See: [ADR-123](link/to/adr-123)Bad comments:
- Wall-of-text explanations
- Restating obvious code (
// increment i by 1) - Comments that could be replaced by better naming or extracted functions
Replace comments with code:
// Instead of commenting each step:
fn process_request(request: T) -> Result<(), Error> {
validate_request_headers(&request)?;
let payload = decode_payload(&request);
authorize(&payload)?;
dispatch_to_handler(payload)
}TODOs:
Turn TODOs into tracked issues. Reference in code:
// TODO(#42): Remove workaround after bugfixMacros
- Avoid macros unless necessary; prefer functions or generics
- Macro input should look like valid Rust syntax
- Use macros only when compile-time code generation or syntax extension is genuinely needed
Newtypes and Data Patterns
- Use newtypes for domain semantics:
struct Email(String) - Prefer slice patterns:
if let [first, .., last] = slice - Use arrays for fixed sizes; avoid
Vecwhen length is known at compile time - Use shadowing for transformations:
let x = x.parse()? - Pre-allocate collections:
Vec::with_capacity(),String::with_capacity()
Synchronous Concurrency
Send and Sync Traits
Rust tracks thread safety via Send and Sync:
- `Send`: data can move across threads
- `Sync`: data can be referenced from multiple threads (
&TisSend)
A pointer is thread-safe only if the data behind it is.
Atomics Over Mutex for Primitives
For bool, usize, and other primitive types, use atomics instead of Mutex:
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
static RUNNING: AtomicBool = AtomicBool::new(true);
static COUNTER: AtomicUsize = AtomicUsize::new(0);
// Read
let is_running = RUNNING.load(Ordering::Relaxed);
let count = COUNTER.load(Ordering::SeqCst);
// Write
RUNNING.store(false, Ordering::Relaxed);
COUNTER.fetch_add(1, Ordering::SeqCst);Memory Ordering
Choose ordering carefully based on the consistency guarantee needed:
| Ordering | Guarantee | Use When |
|---|---|---|
Relaxed | No ordering guarantee | Counters, flags where ordering doesn't matter |
Acquire | Reads after this see writes before the paired Release | Reading shared data after a flag check |
Release | Writes before this are visible after the paired Acquire | Writing shared data before setting a flag |
AcqRel | Both Acquire and Release | Read-modify-write operations |
SeqCst | Total ordering across all threads | When in doubt (highest cost) |
When unsure, use SeqCst. Optimize to weaker orderings only with clear reasoning.
Mutex and RwLock
std::sync::Mutex
Exclusive access — one thread at a time:
use std::sync::{Arc, Mutex};
let data = Arc::new(Mutex::new(0));
let data_clone = Arc::clone(&data);
std::thread::spawn(move || {
let mut lock = data_clone.lock().unwrap();
*lock += 1;
});parking_lot::Mutex (Recommended)
parking_lot::Mutex is a drop-in replacement with better performance:
- No poisoning (simpler API, no
.unwrap()on lock) - Smaller memory footprint
- Better performance under contention
use parking_lot::Mutex;
use std::sync::Arc;
let data = Arc::new(Mutex::new(0));
let data_clone = Arc::clone(&data);
std::thread::spawn(move || {
let mut lock = data.lock(); // No .unwrap() needed
*lock += 1;
});std::sync::RwLock
Multiple readers OR single writer:
use std::sync::{Arc, RwLock};
let data = Arc::new(RwLock::new(vec![1, 2, 3]));
// Multiple concurrent readers
let read_handle = data.read().unwrap();
println!("{:?}", *read_handle);
// Exclusive writer
let mut write_handle = data.write().unwrap();
write_handle.push(4);Prefer RwLock over Mutex for read-heavy workloads. parking_lot::RwLock is also recommended.
Lock Ordering to Prevent Deadlocks
When acquiring multiple locks, always acquire them in a consistent order:
// Define a global ordering: lock_a before lock_b
let lock_a = Arc::new(Mutex::new(0));
let lock_b = Arc::new(Mutex::new(0));
// Always acquire in order: a then b
let _a = lock_a.lock().unwrap();
let _b = lock_b.lock().unwrap();
// NEVER: b then a (deadlock risk)Rules:
- Document the lock ordering for the codebase
- Consider using a single lock for related data instead of multiple locks
- Use
try_lock()to detect and recover from potential deadlocks
Synchronous Channels
crossbeam::channel (Recommended over std::sync::mpsc)
Better performance, more features:
use crossbeam::channel;
// Bounded channel
let (tx, rx) = channel::bounded(100);
// Unbounded channel
let (tx, rx) = channel::unbounded();
// Select across multiple channels
use crossbeam::select;
select! {
recv(rx1) -> msg => println!("From rx1: {:?}", msg),
recv(rx2) -> msg => println!("From rx2: {:?}", msg),
default => println!("No message available"),
}Use crossbeam::channel for synchronous contexts and tokio channels for async contexts.
Shared State Patterns
Arc<Mutex<T>> for Shared Mutable State
use std::sync::{Arc, Mutex};
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
handles.push(std::thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
}));
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap());RwLock Cache Pattern
use std::sync::{Arc, RwLock};
use std::collections::HashMap;
struct Cache {
data: RwLock<HashMap<String, String>>,
}
impl Cache {
fn get(&self, key: &str) -> Option<String> {
self.data.read().unwrap().get(key).cloned()
}
fn set(&self, key: String, value: String) {
self.data.write().unwrap().insert(key, value);
}
}Best Practices
- Use atomics for primitive types (
bool,usize) — avoidMutexoverhead - Choose memory ordering carefully —
SeqCstwhen unsure, weaker when justified - Identify and document lock ordering to prevent deadlocks
- Prefer
parking_lot::Mutexandparking_lot::RwLockover std equivalents - Prefer
crossbeam::channeloverstd::sync::mpscfor sync channels - Use
RwLockinstead ofMutexfor read-heavy workloads - Prefer channels over shared state when possible
- Use
Arc<Mutex<T>>sparingly — consider architectural alternatives
Comments and Documentation
Comments vs Documentation
| Purpose | // comment | /// doc or //! crate doc |
|---|---|---|
| Describe Why | Yes — tricky reasoning | Not for this |
| Describe API | Not useful | Yes — public interfaces |
| Maintainability | Often becomes obsolete | Tied to code, testable |
| Visibility | Local only | Exported to users and cargo doc |
When to Use // Comments
Use when something can't be expressed clearly in code:
- Safety guarantees:
// SAFETY: pointer is guaranteed non-null by caller - Workarounds or optimizations:
// PERF: caching to avoid repeated OS calls - Legacy or platform-specific behaviors
- Links to Design Docs or ADRs:
// CONTEXT: See [ADR-12](link) - Assumptions or gotchas that aren't obvious
Name your comments: // SAFETY: ..., // PERF: ..., // CONTEXT: ...
When Comments Hurt
Avoid comments that:
- Restate obvious code (
// increment i by 1) - Could be replaced by better naming or smaller functions
- Are long and likely to become stale
- Are
TODOs without tracked issues
Replace Comments with Code
// Instead of commenting each step:
fn process_request(request: T) -> Result<(), Error> {
validate_request_headers(&request)?;
let payload = decode_payload(&request);
authorize(&payload)?;
dispatch_to_handler(payload)
}Comments as "Living Documentation" Is Dangerous
- Comments rot — nobody compiles them
- Comments mislead — readers assume they are true
- Comments go stale — unless maintained with the code
If something deserves to persist, put it in:
- An ADR (Architectural Decision Record)
- A Design Document
- Doc comments (
///) where they can be tested - Tests that cover and explain the behavior
TODO Policy
Don't leave // TODO: without a tracked issue:
// TODO(#42): Remove workaround after bugfixDoc Comments: /// and //!
/// — Item-Level Documentation
For functions, structs, traits, enums, constants:
/// Loads a [`User`] profile from disk.
///
/// # Errors
/// - Returns [`MyError::FileNotFound`] if the file is missing.
/// - Returns [`MyError::InvalidJson`] if content is invalid JSON.
///
/// # Examples
///
/// ```rust
/// # use my_crate::load_user;
/// let user = load_user(std::path::Path::new("user.json")).unwrap();
/// ```
pub fn load_user(path: &std::path::Path) -> Result<User, MyError> { /* ... */ }Guidelines:
- Write clear what it does and how to use it
- Include
# Examplesthat can run as tests viacargo test - Use
# Panics,# Errors,# Safetysections when relevant - Hide boilerplate in examples with
#prefix
//! — Module/Crate-Level Documentation
Place at the top of lib.rs or mod.rs:
//! This module implements a custom chess engine.
//!
//! It handles board state, move generation and check detection.
//!
//! # Example
//! ```
//! let board = chess::engine::Board::default();
//! assert!(board.is_valid());
//! ```Documentation Lints
| Lint | Description |
|---|---|
missing_docs | Public items missing documentation |
broken_intra_doc_links | Broken internal [links] in docs |
empty_docs | Prevents bypassing missing_docs with empty comments |
missing_panics_doc | Functions that can panic need # Panics section |
missing_errors_doc | Functions returning Result need # Errors section |
missing_safety_doc | Unsafe functions need # Safety section |
Enable in libraries: #![deny(missing_docs)]
Documentation Coverage Checklist
Crate-Level (lib.rs)
//!doc explains what the crate does and what problems it solves- Includes
# Examplesor pointers to modules
Modules (mod.rs)
//!doc explains what the module is for, its exports, and invariants- Avoid repeating docs on re-exported items
Structs, Enums, Traits
///doc explains the role, invariants, and example usage- Consider
#[non_exhaustive]for enums external users may match on
Functions and Methods
///covers: what it does, parameters, return value, edge cases- Include
# Examples,# Panics,# Errorswhere applicable
Traits
- Explain the purpose (marker? dynamic dispatch?)
- Document each method — when/why to implement it
- Document default implementations and when to override
Public Constants
- Document what they configure and when to use them
Best Practices
- Use examples generously — they double as test cases
- Prefer clarity over formality
- Use
cargo doc --opento check output often - Add relevant doc lints to enforce coverage
Error Handling
Prefer Result, Avoid Panic
Return Result<T, E> for fallible operations. Reserve panic! for unrecoverable bugs:
fn divide(x: f64, y: f64) -> Result<f64, DivisionError> {
if y == 0.0 {
Err(DivisionError::DividedByZero)
} else {
Ok(x / y)
}
}Alternatives to panic!:
todo!()— alerts the compiler that code is missingunreachable!()— asserts a condition is impossibleunimplemented!()— alerts a block is not yet implemented
Unwrap and Expect Policy
No `unwrap()` in production code. Use expect() with descriptive message only when the value is logically guaranteed. Prefer ?, if let, let...else for all other cases.
Alternatives to unwrap/expect:
Use let...else for early returns without needing the error value:
let Ok(json) = serde_json::from_str(&input) else {
return Err(MyError::InvalidJson);
};Use if let...else when recovery requires computation:
if let Ok(json) = serde_json::from_str(&input) {
// computation
} else {
Err(do_something_with_input(&input))
}Use unwrap_or, unwrap_or_else, or unwrap_or_default for fallback values.
Use assert! at function entry for invariant checking (panics in debug, can be optimized away in release).
The ? Operator
Prefer ? over verbose match chains for error propagation:
fn handle_request(req: &Request) -> Result<ValidatedRequest, MyError> {
validate_headers(req)?;
validate_body_format(req)?;
validate_credentials(req)?;
let body = Body::try_from(req)?;
Ok(ValidatedRequest::try_from((req, body))?)
}For error recovery, use or_else, map_err, or if let Ok(..) else. To inspect or log errors, use inspect_err.
Prevent Early Allocation
Use _else variants to avoid eager allocation:
// Good: closure only runs on None
x.ok_or_else(|| ParseError::ValueAbsent(format!("value {x}")))
// Bad: format! always runs, even on Some
x.ok_or(ParseError::ValueAbsent(format!("value {x}")))Same applies to map_or vs map_or_else, unwrap_or vs unwrap_or_else.
Mapping Errors
Use inspect_err for logging and map_err for transforming:
result
.inspect_err(|err| tracing::error!("function_name: {err}"))
.map_err(|err| GeneralError::from(("function_name", err)))?;thiserror for Library/Crate Errors
Use thiserror for structured, typed errors with automatic Display and From implementations:
#[derive(Debug, thiserror::Error)]
pub enum MyError {
#[error("Network Timeout")]
Timeout,
#[error("Invalid data: {0}")]
InvalidData(String),
#[error(transparent)]
Serialization(#[from] serde_json::Error),
#[error("Invalid request. Header: {headers}, Metadata: {metadata}")]
InvalidRequest { headers: Headers, metadata: Metadata },
}Error Hierarchies
For layered systems, use nested errors with #[from]:
#[derive(Debug, thiserror::Error)]
pub enum ServiceError {
#[error("Database error: {0}")]
Db(#[from] DbError),
#[error("Network error: {0}")]
Network(#[from] reqwest::Error),
#[error("Not found: {0}")]
NotFound(String),
#[error("Timeout after {0:?}")]
Timeout(std::time::Duration),
}Custom Error Structs
When there is only one error type, use a struct instead of an enum:
#[derive(Debug, thiserror::Error, PartialEq)]
#[error("Request failed with code `{code}`: {message}")]
struct HttpError {
code: u16,
message: String,
}anyhow for Binaries Only
anyhow erases type info, making it unsuitable for libraries. Use only in binaries:
use anyhow::{Context, Result, bail, ensure};
fn main() -> Result<()> {
let content = std::fs::read_to_string("config.json")
.context("Failed to read config file")?;
ensure!(!content.is_empty(), "File is empty");
if content.len() > MAX_SIZE {
bail!("File too large");
}
Config::from_str(&content)
.map_err(|err| anyhow::anyhow!("Config parsing error: {err}"))
}Gotchas:
- Keeping
context()strings up-to-date across a codebase is harder thanthiserrormessages anyhow::Resulterases context a caller might need — avoid in libraries- Test helpers can use
anyhowfreely
Error Conversion with From Trait
Implement From manually when not using thiserror:
#[derive(Debug)]
enum MyError {
Io(io::Error),
Parse(ParseIntError),
}
impl From<io::Error> for MyError {
fn from(err: io::Error) -> Self { MyError::Io(err) }
}
impl From<ParseIntError> for MyError {
fn from(err: ParseIntError) -> Self { MyError::Parse(err) }
}
// Now ? works with automatic conversion
fn read_and_parse(path: &str) -> Result<i32, MyError> {
let content = std::fs::read_to_string(path)?;
let number = content.trim().parse()?;
Ok(number)
}Option and Result Combinators
// Option combinators
let doubled = Some(5).map(|n| n * 2); // Some(10)
let chained = Some(5).and_then(|n| if n > 0 { Some(n * 2) } else { None });
let fallback = None.unwrap_or_else(|| expensive_computation());
let filtered = Some(5).filter(|&n| n > 10); // None
// Result combinators
let mapped = Ok(5).map(|n| n * 2); // Ok(10)
let err_mapped = Err("err").map_err(|e| e.to_uppercase());
let recovered = Err("err").or_else(|_| Ok(42)); // Ok(42)
// Converting between Option and Result
let opt: Option<i32> = Ok(5).ok(); // Some(5)
let res: Result<i32, &str> = Some(5).ok_or("missing");Async Error Handling
Ensure errors implement Send + Sync + 'static at .await boundaries:
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
Ok(())
}Timeout Wrapping Pattern
async fn with_timeout<T, F>(duration: Duration, future: F) -> Result<T, ServiceError>
where
F: std::future::Future<Output = Result<T, ServiceError>>,
{
tokio::time::timeout(duration, future)
.await
.map_err(|_| ServiceError::Timeout(duration))?
}Context Chaining with anyhow
async fn process_request(id: &str) -> anyhow::Result<Response> {
let data = fetch_data(id).await.context("Failed to fetch data")?;
let parsed = parse_response(&data).context("Failed to parse response")?;
Ok(parsed)
}Advanced Patterns
ContextError Extension Trait
Build custom context support without anyhow:
#[derive(thiserror::Error, Debug)]
#[error("{message}")]
struct ContextError {
message: String,
#[source]
source: Option<Box<dyn Error + Send + Sync>>,
}
trait Context<T> {
fn context(self, message: impl Into<String>) -> Result<T, ContextError>;
}
impl<T, E: Error + Send + Sync + 'static> Context<T> for Result<T, E> {
fn context(self, message: impl Into<String>) -> Result<T, ContextError> {
self.map_err(|e| ContextError {
message: message.into(),
source: Some(Box::new(e)),
})
}
}Try Blocks (Nightly)
#![feature(try_blocks)]
let result: Result<i32, Box<dyn Error>> = try {
let file = std::fs::read_to_string("config.txt")?;
let num: i32 = file.trim().parse()?;
num * 2
};Box<dyn Error> for Multiple Sources
Use when prototyping or when precise error types are not needed:
fn complex_operation() -> Result<String, Box<dyn Error>> {
let file = std::fs::read_to_string("data.txt")?;
let number: i32 = file.trim().parse()?;
Ok(format!("Number: {}", number))
}Avoid Box<dyn std::error::Error> in libraries unless truly necessary.
Testing Errors
Errors often don't implement PartialEq. Test messages with to_string():
#[test]
fn error_message_is_correct() {
let err = divide(10., 0.0).unwrap_err();
assert_eq!(err.to_string(), "division by zero");
}
#[test]
fn error_variant_matches() {
let err = process(my_value).unwrap_err();
assert!(matches!(err, MyError::BadInput(_)));
}Ownership, Borrowing, and Pointers
Move Semantics and Borrowing
// Move semantics (ownership transfer)
fn take_ownership(s: String) {
println!("{}", s);
} // s dropped here
// Immutable borrowing
fn borrow(s: &str) {
println!("{}", s);
} // caller still owns
// Mutable borrowing
fn borrow_mut(s: &mut String) {
s.push_str(" world");
}Lifetime Annotations
Use meaningful lifetime names: 'src, 'ctx, 'conn — not just 'a.
fn longest<'src>(x: &'src str, y: &'src str) -> &'src str {
if x.len() > y.len() { x } else { y }
}
// Lifetime in structs
struct Excerpt<'src> {
part: &'src str,
}
// Static lifetime (lives for entire program)
const GREETING: &'static str = "Hello, world!";Pointer Type Reference
| Pointer | Send+Sync? | Primary Use |
|---|---|---|
&T | Yes | Shared immutable access |
&mut T | Not Send | Exclusive mutable access |
Box<T> | Yes (if T: Send+Sync) | Heap allocation, single owner, recursive types |
Rc<T> | Neither | Shared ownership, single-threaded |
Arc<T> | Yes | Shared ownership, multi-threaded |
Cell<T> | Not Sync | Interior mutability, Copy types only |
RefCell<T> | Not Sync | Interior mutability, runtime borrow checking |
Mutex<T> | Yes | Thread-safe exclusive access |
RwLock<T> | Yes | Thread-safe shared read OR exclusive write |
OnceCell<T> | Not Sync | Single-thread one-time initialization |
LazyCell<T> | Not Sync | Lazy version of OnceCell with closure init |
OnceLock<T> | Yes | Thread-safe one-time initialization |
LazyLock<T> | Yes | Thread-safe lazy initialization with closure |
*const T / *mut T | No | Raw pointers, FFI (inherently unsafe) |
Smart Pointers
Box<T> — Heap Allocated, Single Owner
Great for recursive types and large structs:
enum Tree<T> {
Leaf(T),
Branch(Box<Tree<T>>, Box<Tree<T>>),
}Rc<T> and Arc<T> — Reference Counting
use std::rc::Rc;
use std::sync::Arc;
// Rc: single-threaded shared ownership
let rc1 = Rc::new(vec![1, 2, 3]);
let rc2 = Rc::clone(&rc1);
println!("Count: {}", Rc::strong_count(&rc1)); // 2
// Arc: thread-safe shared ownership
let arc1 = Arc::new(vec![1, 2, 3]);
let arc2 = Arc::clone(&arc1);
std::thread::spawn(move || println!("{:?}", arc2));Arc<Mutex<T>> Pattern
For shared mutable state across threads:
let counter = Arc::new(Mutex::new(0));
let counter_clone = Arc::clone(&counter);
std::thread::spawn(move || {
let mut num = counter_clone.lock().unwrap();
*num += 1;
});Interior Mutability
Cell<T> — Copy Types Only
Fast, no runtime overhead for borrow checking:
use std::cell::Cell;
struct SomeStruct {
regular_field: u8,
special_field: Cell<u8>,
}
let s = SomeStruct { regular_field: 0, special_field: Cell::new(1) };
s.special_field.set(100); // OK even though s is immutableRefCell<T> — Runtime Borrow Checking
Use try_borrow() to avoid panics:
use std::cell::RefCell;
let data = RefCell::new(vec![1, 2, 3]);
data.borrow_mut().push(4);
// Prefer try_borrow to avoid panics
if let Ok(mut val) = data.try_borrow_mut() {
val.push(5);
}Mock Objects with Interior Mutability
struct MockLogger {
messages: RefCell<Vec<String>>,
}
impl MockLogger {
fn new() -> Self {
Self { messages: RefCell::new(Vec::new()) }
}
fn log(&self, msg: &str) {
self.messages.borrow_mut().push(msg.to_string());
}
fn get_messages(&self) -> Vec<String> {
self.messages.borrow().clone()
}
}Pin and Self-Referential Types
Self-referential structs require Pin to prevent moves:
use std::pin::Pin;
use std::marker::PhantomPinned;
struct SelfReferential {
data: String,
pointer: *const String,
_pin: PhantomPinned,
}
impl SelfReferential {
fn new(data: String) -> Pin<Box<Self>> {
let mut boxed = Box::pin(Self {
data,
pointer: std::ptr::null(),
_pin: PhantomPinned,
});
let ptr = &boxed.data as *const String;
// SAFETY: not moving the data after this point
unsafe {
let mut_ref = Pin::as_mut(&mut boxed);
Pin::get_unchecked_mut(mut_ref).pointer = ptr;
}
boxed
}
}Futures are often self-referential, which is why Pin appears in async contexts.
Cow (Clone on Write)
Avoid allocation when data might not need modification:
use std::borrow::Cow;
fn process_text(input: &str) -> Cow<str> {
if input.contains("bad") {
Cow::Owned(input.replace("bad", "good")) // Allocates
} else {
Cow::Borrowed(input) // No allocation
}
}
fn hello_greet(name: Cow<'_, str>) {
println!("Hello {name}");
}
hello_greet(Cow::Borrowed("Julia"));
hello_greet(Cow::Owned("Naomi".to_string()));Drop Trait and RAII
Implement Drop for automatic cleanup:
struct FileGuard { name: String }
impl FileGuard {
fn new(name: String) -> Self {
println!("Opening {}", name);
Self { name }
}
}
impl Drop for FileGuard {
fn drop(&mut self) {
println!("Closing {}", self.name);
}
}
// Usage: automatic cleanup when scope ends
{
let _file = FileGuard::new("data.txt".to_string());
} // Drop called automaticallyBuilder Pattern with Ownership
Consuming builder that transfers ownership at each step:
struct ConfigBuilder {
host: Option<String>,
port: Option<u16>,
}
impl ConfigBuilder {
fn host(mut self, host: impl Into<String>) -> Self {
self.host = Some(host.into());
self
}
fn port(mut self, port: u16) -> Self {
self.port = Some(port);
self
}
fn build(self) -> Result<Config, &'static str> {
Ok(Config {
host: self.host.ok_or("host required")?,
port: self.port.unwrap_or(8080),
})
}
}OnceLock and LazyLock
For static initialization, replacing lazy_static! and once_cell:
use std::sync::OnceLock;
static CELL: OnceLock<u32> = OnceLock::new();
std::thread::spawn(|| {
let value = CELL.get_or_init(|| 12345);
assert_eq!(value, &12345);
}).join().unwrap();use std::sync::LazyLock;
static CONFIG: LazyLock<HashMap<&str, T>> = LazyLock::new(|| {
let data = read_config();
let mut config: HashMap<&str, T> = data.into();
config.insert("special_case", T::default());
config
});Shadowing for Transformations
Use shadowing to transform values without new variable names:
let x = "42";
let x = x.parse::<i32>()?;
let x = x * 2;Best Practices
- Prefer borrowing (
&T) over ownership transfer when possible - Use
&stroverString,&[T]overVec<T>for function parameters - Clone only when necessary (profile first)
- Use
Cow<'a, T>for conditional cloning - Document lifetime relationships in complex cases
- Use
Arc<Mutex<T>>for shared mutable state across threads - Use
Rc<RefCell<T>>for shared mutable state in single thread - Implement
Dropfor RAII patterns - Use
try_borrow()onRefCellto avoid panics - Use meaningful lifetime names (
'src,'ctx, not just'a)
Performance
Golden Rule
Don't guess, measure.
Rust code is often already fast. Optimize only after finding bottlenecks with evidence.
First Steps
- Use
--releaseflag on builds (debug mode has no optimizations) - Run
cargo clippy -- -D clippy::perffor performance hints - Use
cargo benchfor micro-benchmarks - Use
cargo flamegraphorsamply(macOS) for profiling
Flamegraph
Visualize how much time the CPU spends on each task:
cargo install flamegraph
# Profile release build (default)
cargo flamegraph
# Profile specific binary
cargo flamegraph --bin=stress2
# Profile unit tests
cargo flamegraph --unit-test -- test::in::package
# Profile integration tests
cargo flamegraph --test test_name
# Profile criterion benchmark
cargo flamegraph --bench some_benchmark -- --benchAlways profile with --release. The --dev flag is not realistic.
Reading Flamegraphs
- Y-axis: stack depth —
mainis at the bottom, called functions stack upward - Box width: total CPU time for that function (wider = more CPU or called more often)
- Color: random, not significant
- Thick stacks: heavy CPU usage
- Thin stacks: low intensity (cheap)
Avoid Redundant Cloning
Clone only when truly needed, and at the last moment:
- Only
.clone()if a new owned copy is required - Prefer API designs that take references:
fn process(values: &[T])notfn process(values: Vec<T>) - If only read access is needed, use
.iter()or slices - Auto-cloning in loops is expensive — prefer
.cloned()or.copied()at the iterator chain end
When to Pass Ownership
- Crate API requires owned data
- Overloaded
std::opsbut still need the original - Reference-counted pointers (
Arc,Rc) - HTTP clients (e.g.,
hyper_util::Client) where cloning shares the connection pool - Modeling business logic/state transitions
Use Cow for Maybe-Owned Data
use std::borrow::Cow;
fn process(name: Cow<'_, str>) {
println!("Hello {name}");
}
process(Cow::Borrowed("Julia")); // No allocation
process(Cow::Owned("Naomi".to_string())); // AllocationPre-Allocate Collections
Avoid repeated reallocation:
let mut v = Vec::with_capacity(expected_size);
let mut s = String::with_capacity(expected_len);Use arrays for fixed sizes; avoid Vec when length is known at compile time.
Stack vs Heap
Good Practices
- Keep small types (
impl Copy,usize,bool) on the stack - Avoid passing huge types (>512 bytes) by value — use
&Tor&mut T - Heap-allocate recursive data structures:
enum OctreeNode<T> {
Node(T),
Children(Box<[Node<T>; 8]>),
}- Return small
Copytypes by value
Be Mindful
- Only use
#[inline]when benchmarks prove benefit — Rust is good at auto-inlining - Avoid massive stack allocations:
Box::new([0u8; 65536])first allocates on stack then boxes. Instead usevec![0; 65536].into_boxed_slice() - For large const arrays, use
smallvec— it heap-allocates when the array is too large
Iterators and Zero-Cost Abstractions
Rust iterators are lazy and compiled into tight loops. Chaining .filter(), .map(), .rev(), .collect() has no extra cost.
- Prefer iterators over manual
forloops for collection transforms .iter()creates a reference — hold multiple iterators of the same collection- For summing, prefer
.sum()over.fold()—.sum()is specialized for optimization
Avoid Intermediate Collections
// Bad: useless allocation
let doubled: Vec<_> = items.iter().map(|x| x * 2).collect();
process(doubled);
// Good: pass the iterator
let doubled_iter = items.iter().map(|x| x * 2);
process(doubled_iter);String Performance
- Prefer
s.bytes()overs.chars()for ASCII-only operations contains()on strings is O(n*m) — avoid nested string iteration- Use
format!over+concatenation (avoids intermediate allocations)
Testing
Test Naming and Organization
Use descriptive names that read like sentences:
#[cfg(test)]
mod tests {
mod process {
#[test]
fn should_return_error_when_input_empty() { /* ... */ }
#[test]
fn should_return_blob_when_larger_than_b() { /* ... */ }
}
}Naming scheme: unit_of_work + expected_behavior + state_under_test.
One Assertion Per Test
Keeps tests clear and debugging straightforward:
// Good: one thing per test
#[test]
fn lowercase_letters_are_valid() {
assert!(Thing::parse("abcd").is_ok(), "Parse error: {:?}", Thing::parse("abcd").unwrap_err());
}
#[test]
fn capital_letters_are_invalid() {
assert!(Thing::parse("ABCD").is_err());
}Include formatted failure messages in assertions:
assert_eq!(result, expected, "'result' differs: {}", result.diff(expected));Use matches! for pattern matching without exact value:
assert!(matches!(error, MyError::BadInput(_)), "Expected BadInput, found {error}");Parameterized Tests with rstest
Avoid boilerplate for similar tests:
use rstest::rstest;
#[rstest]
#[case::single("a")]
#[case::first_letter("ab")]
#[case::last_letter("ba")]
#[case::in_the_middle("bab")]
fn accepts_all_strings_with_a(#[case] input: &str) {
assert!(the_function(input).is_ok());
}Unit Tests
Tests in the same module as the tested unit. Access to private functions and pub(crate) items.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unit_state_behavior() {
let expected = /* ... */;
let result = /* ... */;
assert_eq!(result, expected, "Failed because {}", result - expected);
}
}- Keep tests simple — KISS
- Test errors and edge cases
- Use
#[ignore = "message"]for incomplete tests - Use
#[should_panic]when panic is the expected behavior
Integration Tests
External tests in tests/ directory. Only test the public API:
tests/
common/
mod.rs # shared utilities
integration_test.rs// tests/integration_test.rs
mod common;
#[test]
fn test_full_workflow() {
let ctx = common::setup();
let result = mylib::process(&ctx.config);
assert!(result.is_ok());
}Use testcontainers for external dependencies (databases, etc.).
Doc Tests
Examples in /// doc comments that run as tests:
/// Adds two numbers.
///
/// # Examples
///
/// ```rust
/// # use crate_name::add;
/// assert_eq!(add(2, 3), 5);
/// ```
pub fn add(a: i32, b: i32) -> i32 { a + b }- Run with
cargo testbut NOTcargo nextest run— usecargo test --docseparately - Hide boilerplate with
#prefix - No issue if doc-tests duplicate unit tests
Doc test attributes:
should_panic— block will panicno_run— compiles but doesn't executecompile_fail— demonstrates wrong usageignore— skip execution
Test Fixtures with Drop
struct TestContext {
temp_dir: std::path::PathBuf,
db: Database,
}
impl TestContext {
fn setup() -> Self {
let temp_dir = std::env::temp_dir().join("test");
std::fs::create_dir_all(&temp_dir).unwrap();
Self { temp_dir, db: Database::connect_test() }
}
}
impl Drop for TestContext {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.temp_dir).ok();
self.db.disconnect();
}
}Async Tests
#[tokio::test]
async fn test_async_function() {
let result = async_operation().await;
assert_eq!(result, 42);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_with_custom_runtime() {
let result = concurrent_operation().await;
assert!(result.is_ok());
}Snapshot Testing with insta
[dev-dependencies]
insta = { version = "1", features = ["yaml"] }Use YAML snapshots for best version control diffs. Install CLI: cargo install cargo-insta.
#[test]
fn test_split_words() {
let words = split_words("hello from the other side");
insta::assert_yaml_snapshot!(words);
}Workflow: cargo insta test then cargo insta review.
Best Practices:
- Use named snapshots:
assert_snapshot!("config/http", config.http) - Keep snapshots small — don't snapshot huge objects
- Don't snapshot primitives — use
assert_eq!instead - Redact unstable fields (timestamps, UUIDs):
insta::assert_json_snapshot!(data, {
".created_at" => "[timestamp]",
".id" => "[uuid]"
});- Commit snapshots to git
- Review changes carefully before accepting
Property-Based Testing with proptest
use proptest::prelude::*;
proptest! {
#[test]
fn reversing_twice_is_identity(ref s in ".*") {
let reversed: String = s.chars().rev().collect();
let double_reversed: String = reversed.chars().rev().collect();
assert_eq!(s, &double_reversed);
}
#[test]
fn addition_is_commutative(a in 0..1000i32, b in 0..1000i32) {
assert_eq!(a + b, b + a);
}
}Custom Strategies
fn user_strategy() -> impl Strategy<Value = User> {
(1..1000u64, "[a-z]{3,10}", "[a-z0-9.]+@[a-z]+\\.[a-z]+")
.prop_map(|(id, name, email)| User { id, name, email })
}
proptest! {
#[test]
fn user_serialization_roundtrip(user in user_strategy()) {
let json = serde_json::to_string(&user).unwrap();
let deserialized: User = serde_json::from_str(&json).unwrap();
assert_eq!(user, deserialized);
}
}Mocking with mockall
use mockall::*;
use mockall::predicate::*;
#[automock]
trait Database {
fn get_user(&self, id: u64) -> Option<User>;
fn save_user(&mut self, user: User) -> Result<(), Error>;
}
#[test]
fn test_with_mock() {
let mut mock = MockDatabase::new();
mock.expect_get_user()
.with(eq(1))
.times(1)
.returning(|_| Some(User { id: 1, name: "Alice".to_string() }));
let user = mock.get_user(1);
assert!(user.is_some());
}Benchmarks with criterion
// benches/my_benchmark.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion, BenchmarkId};
fn criterion_benchmark(c: &mut Criterion) {
c.bench_function("fib 20", |b| b.iter(|| fibonacci(black_box(20))));
}
// Multiple sizes
fn bench_sizes(c: &mut Criterion) {
let mut group = c.benchmark_group("sorting");
for size in [10, 100, 1000, 10000] {
group.bench_with_input(BenchmarkId::from_parameter(size), &size, |b, &size| {
b.iter_batched(
|| generate_random_vec(size),
|mut v| v.sort(),
criterion::BatchSize::SmallInput,
);
});
}
group.finish();
}
criterion_group!(benches, criterion_benchmark, bench_sizes);
criterion_main!(benches);# Cargo.toml
[[bench]]
name = "my_benchmark"
harness = falseDatabase Tests with sqlx
#[sqlx::test]
async fn test_database_operations(pool: sqlx::PgPool) -> sqlx::Result<()> {
sqlx::query("INSERT INTO users (name) VALUES ($1)")
.bind("Alice")
.execute(&pool)
.await?;
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
.fetch_one(&pool)
.await?;
assert_eq!(count.0, 1);
Ok(())
}Fuzzing
// fuzz/fuzz_targets/fuzz_target_1.rs
#![no_main]
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
if let Ok(s) = std::str::from_utf8(data) {
let _ = mylib::parse_input(s);
}
});Setup and run:
cargo install cargo-fuzz
cargo fuzz init
cargo fuzz run fuzz_target_1Code Coverage
# Using tarpaulin
cargo install cargo-tarpaulin
cargo tarpaulin --out Html --output-dir coverage
# Using llvm-cov
cargo install cargo-llvm-cov
cargo llvm-cov --htmlBest Practices
- Write tests alongside production code in
#[cfg(test)]modules - Use integration tests in
tests/for end-to-end testing - Include doctests for public API examples
- Use descriptive test names explaining what is being tested
- Test edge cases (empty inputs, max values, boundaries)
- Use property-based testing for algorithmic code
- Benchmark performance-critical code with criterion
- Run tests in CI with
cargo test --all-features - Run clippy on test code too
- Measure coverage and aim for high coverage on critical paths
- Use fuzzing for security-critical parsers
Traits, Generics, and Type System
Trait Basics
// Trait with default implementation
trait Describable {
fn describe(&self) -> String {
String::from("No description available")
}
}
// Implementing traits
struct Circle { radius: f64 }
impl Describable for Circle {
fn describe(&self) -> String {
format!("A circle with radius {}", self.radius)
}
}Associated Types vs Generic Parameters
Use associated types when there is one clear type per implementation:
trait Container {
type Item;
fn add(&mut self, item: Self::Item);
fn get(&self, index: usize) -> Option<&Self::Item>;
}Use generic parameters when multiple types might be used simultaneously.
Generic Bounds and Where Clauses
fn print_info<T>(item: &T)
where
T: std::fmt::Display + std::fmt::Debug,
{
println!("Display: {}, Debug: {:?}", item, item);
}
// Blanket implementation
impl<T: std::fmt::Display> MyTrait for T {
fn do_something(&self) { println!("Value: {}", self); }
}Static vs Dynamic Dispatch
Static where you can, dynamic where you must.
Static Dispatch: impl Trait or <T: Trait>
Zero runtime cost. The compiler monomorphizes per use:
fn specialized_sum<T: MyTrait>(iter: impl Iterator<Item = T>) -> T {
iter.map(|x| x.random_mapping()).sum()
}Best when: zero runtime cost needed, types known at compile time, tight loops.
Dynamic Dispatch: dyn Trait
Runtime vtable. Use for heterogeneous collections and plugin architectures:
fn all_animals_greeting(animals: Vec<Box<dyn Animal>>) {
for animal in animals {
println!("{}", animal.greet());
}
}Best when: runtime polymorphism needed, different types in one collection, abstracting internals.
Trade-off Summary
Static (impl Trait) | Dynamic (dyn Trait) | |
|---|---|---|
| Performance | Faster, inlined | Slower: vtable indirection |
| Compile time | Slower: monomorphization | Faster: shared code |
| Binary size | Larger: per-type codegen | Smaller |
| Flexibility | One type at a time | Can mix types |
| Errors | Clearer | Erased types confuse errors |
Trait Object Ergonomics
- Prefer
&dyn TraitoverBox<dyn Trait>when ownership is not needed - Use
Arc<dyn Trait>for shared access across threads - Don't box prematurely inside structs — box at public API boundaries
- Object safety: no generic methods, no
Self: Sized, methods use&self/&mut self/self
// Good: generics when possible
struct Renderer<B: Backend> { backend: B }
// Avoid: premature boxing
struct Renderer { backend: Box<dyn Backend> }Extension Traits
Add functionality to existing types:
trait StringExt {
fn truncate_to(&self, max_len: usize) -> String;
}
impl StringExt for str {
fn truncate_to(&self, max_len: usize) -> String {
if self.len() <= max_len { self.to_string() }
else { format!("{}...", &self[..max_len]) }
}
}Sealed Traits
Prevent external implementors:
mod sealed {
pub trait Sealed {}
}
pub trait MySealed: sealed::Sealed {
fn method(&self);
}
struct MyType;
impl sealed::Sealed for MyType {}
impl MySealed for MyType {
fn method(&self) { println!("Implemented"); }
}Supertraits
trait Printable {
fn print(&self);
}
trait Loggable: Printable {
fn log(&self) {
self.print(); // Can call supertrait methods
}
}Associated Constants
trait Config {
const MAX_SIZE: usize;
const DEFAULT_TIMEOUT: u64;
}
struct ServerConfig;
impl Config for ServerConfig {
const MAX_SIZE: usize = 1024;
const DEFAULT_TIMEOUT: u64 = 30;
}Generic Associated Types (GATs)
Allow generics in associated types:
trait LendingIterator {
type Item<'a> where Self: 'a;
fn next<'a>(&'a mut self) -> Option<Self::Item<'a>>;
}
struct WindowsMut<'data, T> {
data: &'data mut [T],
index: usize,
}
impl<'data, T> LendingIterator for WindowsMut<'data, T> {
type Item<'a> = &'a mut [T] where Self: 'a;
fn next<'a>(&'a mut self) -> Option<Self::Item<'a>> {
if self.index >= self.data.len() { return None; }
let start = self.index;
self.index += 2;
Some(&mut self.data[start..start.min(self.data.len())])
}
}Marker Traits and PhantomData
use std::marker::PhantomData;
trait Trusted {}
struct TrustedData<T> {
data: T,
_marker: PhantomData<T>,
}
impl<T: Trusted> TrustedData<T> {
fn new(data: T) -> Self {
Self { data, _marker: PhantomData }
}
}Operator Overloading
use std::ops::{Add, Mul};
#[derive(Debug, Clone, Copy)]
struct Vector2D { x: f64, y: f64 }
impl Add for Vector2D {
type Output = Self;
fn add(self, other: Self) -> Self {
Self { x: self.x + other.x, y: self.y + other.y }
}
}
impl Mul<f64> for Vector2D {
type Output = Self;
fn mul(self, scalar: f64) -> Self {
Self { x: self.x * scalar, y: self.y * scalar }
}
}From/Into and TryFrom/TryInto
struct UserId(u64);
impl From<u64> for UserId {
fn from(id: u64) -> Self { UserId(id) }
}
// Into is automatically implemented
fn accept_user_id(id: impl Into<UserId>) {
let user_id = id.into();
}
// TryFrom for fallible conversions
impl TryFrom<i64> for UserId {
type Error = &'static str;
fn try_from(value: i64) -> Result<Self, Self::Error> {
if value < 0 { Err("User ID cannot be negative") }
else { Ok(UserId(value as u64)) }
}
}Derive Macros
// Standard derives
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct User { id: u64, name: String }
// With serde
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct Config { host: String, port: u16 }Type State Pattern
Encode states as types using generics + PhantomData. Invalid states become compile errors:
use std::marker::PhantomData;
struct FileNotOpened;
struct FileOpened;
struct File<State> {
path: std::path::PathBuf,
handle: Option<std::fs::File>,
_state: PhantomData<State>,
}
impl File<FileNotOpened> {
fn open(path: &std::path::Path) -> std::io::Result<File<FileOpened>> {
let file = std::fs::File::open(path)?;
Ok(File {
path: path.to_path_buf(),
handle: Some(file),
_state: PhantomData::<FileOpened>,
})
}
}
impl File<FileOpened> {
fn read(&mut self) -> std::io::Result<String> {
use std::io::Read;
let mut content = String::new();
self.handle.as_mut().unwrap().read_to_string(&mut content)?;
Ok(content)
}
}Multi-State Builder
struct MissingName;
struct NameSet;
struct MissingAge;
struct AgeSet;
struct Builder<NameState, AgeState> {
name: Option<String>,
age: u8,
_name: PhantomData<NameState>,
_age: PhantomData<AgeState>,
}
impl Builder<NameSet, AgeSet> {
fn build(self) -> Person {
Person { name: self.name.unwrap(), age: self.age }
}
}When to Use Type State:
- Compile-time state safety
- Enforcing API constraints
- Library/crate design dependent on state variants
- Replacing runtime booleans with type-safe code paths
When to Avoid:
- Trivial states (simple enums suffice)
- Runtime flexibility is required
- Leads to overcomplicated generics
PhantomData is zero-sized and removed after compilation — no runtime overhead.
Const Traits (Nightly)
#![feature(const_trait_impl)]
#[const_trait]
trait ConstAdd {
fn add(self, other: Self) -> Self;
}
impl const ConstAdd for i32 {
fn add(self, other: Self) -> Self { self + other }
}
const fn compute() -> i32 { 5.add(10) }Best Practices
- Prefer associated types when one type per implementation
- Use generic parameters when multiple types used simultaneously
- Keep traits small and focused (single responsibility)
- Prefer static dispatch; use
dyn Traitwhen flexibility outweighs speed - Use
#[derive]when possible instead of manual implementations - Implement standard traits (
Debug,Clone, etc.) for ecosystem integration - Use sealed traits to prevent external implementations when needed
- Document trait requirements and invariants