Rust Best Practices
Rust is a systems programming language that guarantees memory safety and thread safety without a garbage collector. Those guarantees come from a rich type system and strict compiler checks. Writing production Rust means going beyond syntax: it means internalizing ownership, composing with traits, handling errors explicitly, and treating unsafe as a last resort with a heavy burden of proof.
This guide collects battle-tested practices for idiomatic Rust code, from small function-level decisions to crate and workspace organization. Whether you are shipping a CLI, a WebAssembly module, an embedded firmware image, or a network service, these patterns will help you write code that is safe, fast, and maintainable.
info
The table below contrasts common Rust anti-patterns with their idiomatic alternatives. These heuristics cover ownership, error handling, API design, and performance. Internalize them and your code will feel more natural to other Rust programmers.
| Avoid | Prefer | Why |
|---|---|---|
| .unwrap() / .expect() in production | ? operator, explicit match, or robust error types | Panics crash the process; users and operators deserve actionable errors |
| Cloning to silence the borrow checker | Restructure ownership, borrow, or use Rc/Arc deliberately | Cloning hides design flaws and adds hidden allocation cost |
| Stringly typed APIs | Newtypes, enums, and custom types for domain concepts | The type system prevents invalid states at compile time |
| Returning () from fallible functions | Result<T, E> with a meaningful error type | Callers cannot ignore recoverable failures |
| Large unsafe blocks | Tiny, documented unsafe blocks with clear invariants | Limits the surface area where assumptions must be manually verified |
| Exposing lifetimes in public APIs unnecessarily | Owned types, Cow<'a, str>, or lifetime elision | Lifetimes leak into caller code and complicate refactoring |
| Manual drop calls | RAII guards, scopes, and the Drop trait | Manual resource management is error-prone |
| Ignoring compiler warnings | #![deny(warnings)] or CI-enforced cargo clippy | Warnings often signal real bugs or API misuse |
| Deeply nested match | ?, combinators, early returns, or helper functions | Flat code is easier to read and audit |
| Global mutable state | Explicit dependency injection, actors, or channels | Easier to test, reason about, and make thread-safe |
warning
Idiomatic Rust leans on the type system to make invalid states unrepresentable. Prefer enums over booleans, newtypes over bare primitives, and iterators over manual indexing. The compiler can verify far more when your data model carries meaning.
| 1 | // Avoid: boolean flags encode state poorly |
| 2 | struct Order { |
| 3 | id: u64, |
| 4 | paid: bool, |
| 5 | shipped: bool, |
| 6 | } |
| 7 | |
| 8 | // Prefer: an enum captures the state machine |
| 9 | #[derive(Debug, Clone)] |
| 10 | enum OrderStatus { |
| 11 | Pending, |
| 12 | Paid { at: chrono::DateTime<chrono::Utc> }, |
| 13 | Shipped { at: chrono::DateTime<chrono::Utc>, tracking: String }, |
| 14 | } |
| 15 | |
| 16 | struct Order { |
| 17 | id: u64, |
| 18 | status: OrderStatus, |
| 19 | } |
Use impl blocks to keep data and behavior together, but avoid turning every struct into a miniature class. Rust favors free functions and trait implementations over inheritance. When a function does not need access to private fields, prefer a free function or a trait method.
| 1 | pub struct Config { |
| 2 | pub path: std::path::PathBuf, |
| 3 | pub verbose: bool, |
| 4 | } |
| 5 | |
| 6 | impl Config { |
| 7 | pub fn new(path: impl Into<std::path::PathBuf>) -> Self { |
| 8 | Self { |
| 9 | path: path.into(), |
| 10 | verbose: false, |
| 11 | } |
| 12 | } |
| 13 | |
| 14 | pub fn verbose(mut self, value: bool) -> Self { |
| 15 | self.verbose = value; |
| 16 | self |
| 17 | } |
| 18 | } |
| 19 | |
| 20 | // Free function for logic that does not need private fields |
| 21 | pub fn load_config(cfg: &Config) -> Result<String, std::io::Error> { |
| 22 | std::fs::read_to_string(&cfg.path) |
| 23 | } |
best practice
Use the ? operator to propagate errors and if let / while let to unpack options without nesting. Combinators such as map, and_then, and unwrap_or keep control flow linear.
| 1 | fn parse_port(env: Option<&str>) -> Result<u16, String> { |
| 2 | env.ok_or_else(|| "PORT env var is missing".to_string())? |
| 3 | .parse::<u16>() |
| 4 | .map_err(|e| format!("PORT is not a valid u16: {e}")) |
| 5 | } |
| 6 | |
| 7 | fn first_even(numbers: &[i32]) -> Option<i32> { |
| 8 | numbers.iter().copied().find(|n| n % 2 == 0) |
| 9 | } |
Rust forces you to acknowledge failure modes. The standard library gives you Result<T, E> and Option<T>; the ecosystem gives you thiserror for structured errors and anyhow for ergonomic propagation in applications.
| 1 | use thiserror::Error; |
| 2 | |
| 3 | #[derive(Debug, Error)] |
| 4 | pub enum ConfigError { |
| 5 | #[error("could not read config file `{path}`")] |
| 6 | Read { path: String, source: std::io::Error }, |
| 7 | |
| 8 | #[error("invalid config: {0}")] |
| 9 | Invalid(String), |
| 10 | |
| 11 | #[error("missing required field `{0}`")] |
| 12 | MissingField(String), |
| 13 | } |
| 14 | |
| 15 | pub fn load(path: &str) -> Result<Config, ConfigError> { |
| 16 | let raw = std::fs::read_to_string(path) |
| 17 | .map_err(|e| ConfigError::Read { path: path.into(), source: e })?; |
| 18 | |
| 19 | parse(&raw) |
| 20 | } |
Libraries should expose structured error types so callers can match on variants. Applications can use anyhow::Result for convenience, but convert to concrete errors at module boundaries. Avoid Box<dyn Error> in public APIs: it erases information.
| 1 | // Library: structured, matchable errors |
| 2 | pub fn decode(input: &[u8]) -> Result<Packet, DecodeError> { ... } |
| 3 | |
| 4 | // Application: ergonomic context propagation |
| 5 | use anyhow::{Context, Result}; |
| 6 | |
| 7 | fn main() -> Result<()> { |
| 8 | let cfg = load_config() |
| 9 | .context("failed to load configuration")?; |
| 10 | run(cfg) |
| 11 | .context("application runtime failed")?; |
| 12 | Ok(()) |
| 13 | } |
danger
When you do panic, make the message actionable. expect("index should be in bounds") is far more useful than expect("failed"). Better yet, use .ok_or_else or assert! with a message explaining the invariant.
Traits are Rust's interface mechanism. Derive them when possible, implement them with intention, and design bounds that are minimal but expressive. A well-designed trait boundary lets you swap implementations without changing callers.
| 1 | // Derive the obvious traits; implement semantics manually |
| 2 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] |
| 3 | pub struct UserId(u64); |
| 4 | |
| 5 | // Newtype pattern: same representation, different semantics |
| 6 | impl UserId { |
| 7 | pub fn new(id: u64) -> Self { |
| 8 | Self(id) |
| 9 | } |
| 10 | |
| 11 | pub fn as_u64(&self) -> u64 { |
| 12 | self.0 |
| 13 | } |
| 14 | } |
| 15 | |
| 16 | // Custom trait for domain behavior |
| 17 | pub trait Repository<T> { |
| 18 | type Error; |
| 19 | fn get(&self, id: &UserId) -> Result<T, Self::Error>; |
| 20 | fn save(&mut self, item: &T) -> Result<(), Self::Error>; |
| 21 | } |
Keep trait bounds as generic as possible. Over-constraining bounds leaks implementation details and makes functions harder to reuse. Prefer bounds on the impl block or individual methods rather than on the type definition itself.
| 1 | // Too restrictive: callers must provide Debug even if unused |
| 2 | fn process<T: Clone + Debug + Serialize>(item: T) { ... } |
| 3 | |
| 4 | // Better: require only what the function uses |
| 5 | fn process<T: Clone>(item: T) { ... } |
| 6 | |
| 7 | // Generic over writers, not tied to Vec<u8> |
| 8 | pub fn render<W: std::io::Write>(output: &mut W) -> std::io::Result<()> { |
| 9 | writeln!(output, "hello") |
| 10 | } |
best practice
Lifetimes are Rust's way of tracking references. They are not a separate concept from ownership; they are a label on borrowed data. Use lifetime elision rules to keep signatures clean, and introduce named lifetimes only when the compiler cannot infer them.
| 1 | // Elision works here: one input lifetime maps to output |
| 2 | fn first_word(s: &str) -> &str { |
| 3 | s.split_whitespace().next().unwrap_or("") |
| 4 | } |
| 5 | |
| 6 | // Explicit lifetimes needed when there are multiple inputs |
| 7 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { |
| 8 | if x.len() >= y.len() { x } else { y } |
| 9 | } |
If you find yourself fighting the borrow checker, ask whether you are trying to store a reference where an owned value is simpler. Returning owned data, using Cow<'a, str>, or accepting a closure instead of returning a reference often resolves lifetime struggles.
| 1 | use std::borrow::Cow; |
| 2 | |
| 3 | // Returns borrowed data when possible, owned when necessary |
| 4 | fn greeting(name: &str) -> Cow<'_, str> { |
| 5 | if name.is_empty() { |
| 6 | Cow::Borrowed("Hello, world!") |
| 7 | } else { |
| 8 | Cow::Owned(format!("Hello, {name}!")) |
| 9 | } |
| 10 | } |
warning
Rust has three built-in test targets: unit tests in #[cfg(test)] modules, integration tests in the tests/ directory, and doctests in documentation comments. Use all three layers to catch bugs at different scopes.
| 1 | /// Parses a percentage in the range 0..=100. |
| 2 | /// |
| 3 | /// # Examples |
| 4 | /// |
| 5 | /// ``` |
| 6 | /// use mycrate::parse_percent; |
| 7 | /// assert_eq!(parse_percent("50"), Ok(50)); |
| 8 | /// ``` |
| 9 | pub fn parse_percent(input: &str) -> Result<u8, String> { |
| 10 | let n: u8 = input.parse().map_err(|e| format!("not a number: {e}"))?; |
| 11 | if n > 100 { |
| 12 | return Err("percentage must be <= 100".into()); |
| 13 | } |
| 14 | Ok(n) |
| 15 | } |
| 16 | |
| 17 | #[cfg(test)] |
| 18 | mod tests { |
| 19 | use super::*; |
| 20 | |
| 21 | #[test] |
| 22 | fn parses_valid_percent() { |
| 23 | assert_eq!(parse_percent("42").unwrap(), 42); |
| 24 | } |
| 25 | |
| 26 | #[test] |
| 27 | fn rejects_out_of_range() { |
| 28 | assert!(parse_percent("101").is_err()); |
| 29 | } |
| 30 | |
| 31 | #[test] |
| 32 | #[should_panic(expected = "percentage must be <= 100")] |
| 33 | fn panics_on_invalid_in_debug_build() { |
| 34 | // Demonstrates panic testing when applicable |
| 35 | parse_percent("200").unwrap(); |
| 36 | } |
| 37 | } |
For tests that depend on time, randomness, or external services, inject dependencies through traits or function parameters. Avoid relying on real clocks or network calls in unit tests; they make tests flaky and slow.
| 1 | pub trait Clock { |
| 2 | fn now(&self) -> std::time::SystemTime; |
| 3 | } |
| 4 | |
| 5 | pub struct SystemClock; |
| 6 | impl Clock for SystemClock { |
| 7 | fn now(&self) -> std::time::SystemTime { |
| 8 | std::time::SystemTime::now() |
| 9 | } |
| 10 | } |
| 11 | |
| 12 | #[derive(Default)] |
| 13 | pub struct FakeClock(std::cell::Cell<std::time::SystemTime>); |
| 14 | |
| 15 | impl Clock for FakeClock { |
| 16 | fn now(&self) -> std::time::SystemTime { |
| 17 | self.0.get() |
| 18 | } |
| 19 | } |
| 20 | |
| 21 | pub fn is_expired<C: Clock>(clock: &C, deadline: std::time::SystemTime) -> bool { |
| 22 | clock.now() > deadline |
| 23 | } |
info
Cargo is more than a build tool: it manages dependencies, runs tests, benchmarks, documentation, and cross-compilation. A clean Cargo setup reduces friction for contributors and prevents subtle build issues in CI.
| 1 | [package] |
| 2 | name = "my-service" |
| 3 | version = "0.1.0" |
| 4 | edition = "2024" |
| 5 | rust-version = "1.85" |
| 6 | license = "MIT OR Apache-2.0" |
| 7 | repository = "https://github.com/example/my-service" |
| 8 | |
| 9 | [dependencies] |
| 10 | serde = { version = "1.0", features = ["derive"] } |
| 11 | tokio = { version = "1", features = ["rt-multi-thread", "macros"] } |
| 12 | |
| 13 | [dev-dependencies] |
| 14 | tempfile = "3" |
| 15 | |
| 16 | [profile.release] |
| 17 | opt-level = 3 |
| 18 | lto = "thin" |
| 19 | codegen-units = 1 |
| 20 | strip = true |
| 21 | |
| 22 | [workspace] |
| 23 | members = ["crates/*"] |
| 24 | resolver = "3" |
Pin your MSRV (Minimum Supported Rust Version) with rust-version so consumers know what compiler they need. Use workspace inheritance to share metadata and dependency versions across crates. Enable lto in release profiles for maximum optimization, but measure compile-time impact.
| 1 | # Format check |
| 2 | cargo fmt -- --check |
| 3 | |
| 4 | # Lint with clippy; deny warnings in CI |
| 5 | cargo clippy --all-targets --all-features -- -D warnings |
| 6 | |
| 7 | # Run tests including doctests |
| 8 | cargo test --all-features |
| 9 | |
| 10 | # Build documentation and catch broken links |
| 11 | cargo doc --no-deps |
| 12 | |
| 13 | # Check for known security advisories in dependencies |
| 14 | cargo audit |
best practice
Rust's zero-cost abstractions let you write high-level code that compiles to efficient machine code. But abstraction is not free by default: allocations, copies, and cache misses still matter. Profile before optimizing, then target the hot paths.
| 1 | // Prefer iterators over manual indexing |
| 2 | let sum: i32 = values.iter().copied().filter(|x| x > 0).sum(); |
| 3 | |
| 4 | // Pre-allocate when the final size is known |
| 5 | let mut buf = Vec::with_capacity(expected_len); |
| 6 | |
| 7 | // Use &str instead of String for borrowed data |
| 8 | fn count_words(text: &str) -> usize { |
| 9 | text.split_whitespace().count() |
| 10 | } |
| 11 | |
| 12 | // Avoid unnecessary clones by returning references or slices |
| 13 | fn first_line(text: &str) -> &str { |
| 14 | text.lines().next().unwrap_or("") |
| 15 | } |
Use cargo bench with the criterion crate for statistically rigorous benchmarks. Always compare against a baseline, and run benchmarks on quiet, consistent hardware. Microbenchmarks can be misleading; validate with real workloads when possible.
| 1 | use criterion::{black_box, criterion_group, criterion_main, Criterion}; |
| 2 | |
| 3 | fn fib(n: u64) -> u64 { |
| 4 | match n { |
| 5 | 0 | 1 => n, |
| 6 | _ => fib(n - 1) + fib(n - 2), |
| 7 | } |
| 8 | } |
| 9 | |
| 10 | fn criterion_benchmark(c: &mut Criterion) { |
| 11 | c.bench_function("fib 20", |b| b.iter(|| fib(black_box(20)))); |
| 12 | } |
| 13 | |
| 14 | criterion_group!(benches, criterion_benchmark); |
| 15 | criterion_main!(benches); |
warning
Unsafe Rust disables some compiler checks so you can interface with hardware, C libraries, or implement core data structures. It does not disable the borrow checker entirely, and it does not make undefined behavior safe. Treat every unsafe block as a contract you must prove manually.
| 1 | /// SAFETY: `ptr` must be non-null, properly aligned, and point to a valid |
| 2 | /// initialized value of type `T`. After this call, the caller must not use `ptr` |
| 3 | /// again unless ownership is returned. |
| 4 | unsafe fn deref_owned<T>(ptr: *const T) -> T |
| 5 | where |
| 6 | T: Copy, |
| 7 | { |
| 8 | *ptr |
| 9 | } |
| 10 | |
| 11 | pub fn safe_wrapper<T: Copy>(ptr: *const T) -> Option<T> { |
| 12 | if ptr.is_null() { |
| 13 | return None; |
| 14 | } |
| 15 | // Keep unsafe blocks small and document invariants |
| 16 | Some(unsafe { deref_owned(ptr) }) |
| 17 | } |
Encapsulate unsafe behind safe APIs. The unsafe keyword should appear as close to the actual invariant as possible, and the safe wrapper should enforce preconditions. Use miri to detect undefined behavior in test suites, especially when working with raw pointers or custom allocators.
| Rule | Reason |
|---|---|
| Keep unsafe blocks minimal | Reduces the lines of code you must manually verify |
| Document invariants with // SAFETY: | Future reviewers need to know why the block is sound |
| Use safe wrappers | Callers should not need to reason about unsafe invariants |
| Run Miri in CI | Catches UB that passes normal tests |
| Avoid undefined behavior even if it "works" | UB can be exploited by future compiler optimizations |
danger
Even experienced Rust developers hit recurring traps. Recognizing them early saves debugging time and produces cleaner code.
| 1 | // Pitfall 1: holding a lock across an await point |
| 2 | async fn bad(cache: &std::sync::Mutex<Vec<u64>>) { |
| 3 | let _guard = cache.lock().unwrap(); |
| 4 | // await while holding a sync lock โ blocks the executor |
| 5 | tokio::time::sleep(std::time::Duration::from_secs(1)).await; |
| 6 | } |
| 7 | |
| 8 | // Fix: use tokio::sync::Mutex for async code, or drop the guard before await |
| 9 | async fn good(cache: &tokio::sync::Mutex<Vec<u64>>) { |
| 10 | let data = { |
| 11 | let guard = cache.lock().await; |
| 12 | guard.clone() |
| 13 | }; |
| 14 | tokio::time::sleep(std::time::Duration::from_secs(1)).await; |
| 15 | drop(data); |
| 16 | } |
| 17 | |
| 18 | // Pitfall 2: infinite recursion in Drop |
| 19 | impl Drop for Node { |
| 20 | fn drop(&mut self) { |
| 21 | drop(self.next.take()); // can stack overflow on long lists |
| 22 | } |
| 23 | } |
Other common mistakes include matching on &Option<T> without as_ref, shadowing names in nested closures, forgetting that HashMap iteration order is nondeterministic, and assuming Vec::drain keeps capacity unchanged. Read compiler warnings carefully; they usually catch these.
| 1 | // Pitfall 3: matching on a reference without as_ref |
| 2 | let maybe: Option<String> = Some("hello".into()); |
| 3 | |
| 4 | // Bad: moves out of borrowed content |
| 5 | // match &maybe { Some(s) => ... } // s is &String, OK |
| 6 | // match maybe { Some(s) => ... } // consumes maybe |
| 7 | |
| 8 | // Good: borrow explicitly |
| 9 | if let Some(s) = maybe.as_ref() { |
| 10 | println!("{s}"); |
| 11 | } |
| 12 | |
| 13 | // Pitfall 4: implicit copies in async move closures |
| 14 | let data = vec![1, 2, 3]; |
| 15 | tokio::spawn(async move { |
| 16 | // data is moved here; ensure this is intended |
| 17 | println!("{:?}", data); |
| 18 | }); |
note
Use this checklist for every Rust pull request. Automate what you can with CI, and reserve human review for architecture, correctness, and safety arguments.
| Check | Automated? | Details |
|---|---|---|
| Compiles on stable | cargo build | Check MSRV and all feature combinations |
| No warnings | cargo clippy -D warnings | Warnings often indicate real issues |
| Tests pass | cargo test --all-features | Unit, integration, and doctests |
| Error handling | Review | No unwrap on external input; meaningful errors |
| Unsafe soundness | Review + Miri | Small blocks, documented invariants, safe wrappers |
| API ergonomics | Review | Builder, Default, Into arguments where appropriate |
| Performance | Profile | No speculative optimization without data |
| Documentation | cargo doc | Public items have doc comments with examples |
| Dependencies | cargo audit | No known vulnerabilities; justified additions |
These references are the authoritative sources behind the recommendations in this guide. Use them for deep dives into specific language features and ecosystem tools.
| Resource | Topic |
|---|---|
| The Rust Programming Language | doc.rust-lang.org/book โ official Rust book |
| Rust By Example | doc.rust-lang.org/rust-by-example โ hands-on examples |
| The Rust Reference | doc.rust-lang.org/reference โ language specification |
| Rust API Guidelines | rust-lang.github.io/api-guidelines โ idiomatic API design |
| The Rustonomicon | doc.rust-lang.org/nomicon โ advanced unsafe Rust |
| Clippy Lints | rust-lang.github.io/rust-clippy โ lint documentation |
Community
Get help on Slack, Discord or VIP
Stuck on a guide? Join the community and ask.