Rust Best Practices
Rust’s compiler stops you from writing memory-unsafe code, but it says nothing about whether your code is clear, efficient, or pleasant for someone else to maintain. Best practices are the layer of judgment on top of what the compiler enforces: how to shape a function signature, when to borrow instead of clone, how to handle errors without littering the code with panics, and how to lean on Rust’s iterator and type system instead of fighting them. This lesson pulls together the idioms experienced Rust developers reach for by default, and explains the reasoning behind each one so you can apply it with judgment, not just by rote.
Overview: What “Best Practices” Means in Rust
The borrow checker guarantees two things: no data race, and no use of freed or aliased memory. It does this by tracking who owns each value and who is allowed to borrow it, at compile time, with zero runtime cost. That guarantee is non-negotiable and you already saw how it works in earlier lessons. Best practice is a different, softer layer built on top: given that your code already satisfies the borrow checker, which of the many ways to satisfy it produces the clearest, fastest, most reusable code?
Two habits drive almost every other guideline in this lesson. First, design function signatures around borrowing: a function that only needs to read data should accept a reference (&str, &[T]), not take ownership of a String or Vec<T> it doesn’t need to keep. This lets callers keep using their data afterward and avoids allocations the function doesn’t actually need. Second, make failure part of the type system instead of a runtime surprise: use Option<T> for values that might be absent and Result<T, E> for operations that might fail, and propagate failures with the ? operator rather than reaching for .unwrap() and hoping nothing goes wrong. Community tooling reinforces both habits: cargo fmt enforces a single, consistent formatting style so code reviews focus on logic instead of whitespace, and cargo clippy is a linter that catches dozens of “technically correct, not idiomatic” patterns the compiler itself won’t flag, including several of the mistakes covered later in this lesson.
Syntax: The Shape of Idiomatic Rust
There’s no single syntax form for “best practices,” but idiomatic Rust functions tend to follow a recognizable shape: borrow what you only need to read, and return owned data or a Result/Option that makes failure explicit.
fn function_name(input: &str) -> Result<Output, MyError> {
let parsed = step_one(input)?;
let result = step_two(parsed)?;
Ok(result)
}
| Situation | Prefer | Avoid |
|---|---|---|
| Reading a string parameter | &str |
String when you only read it |
| Reading a collection parameter | &[T] |
&Vec<T> |
| An operation that can fail | Result<T, E> with ? |
.unwrap()/.expect() in library code |
| A value that might be missing | Option<T> |
Sentinel values like -1 or "" |
| Looping over a collection | Iterator adapters (map, filter, sum) |
Manual index loops with a counter |
| Fixing a borrow error | Restructure scope/lifetimes first | Reaching for .clone() by reflex |
Examples
Example 1: Borrowing Instead of Cloning
fn print_names(names: &[String]) {
for name in names {
println!("Hello, {name}!");
}
}
fn main() {
let names = vec![String::from("Ferris"), String::from("Ada")];
print_names(&names);
println!("We still own {} names", names.len());
}
Output:
Hello, Ferris!
Hello, Ada!
We still own 2 names
print_names takes &[String] — a borrowed slice — instead of Vec<String> or even &Vec<String>. Passing &names triggers deref coercion from &Vec<String> to &[String] automatically, no allocation happens, and because the function only borrowed the data, main still owns names and can use it again on the next line. Accepting a slice also means the function works for arrays and other slice-producing sources, not only a Vec.
Example 2: Propagating Errors with ? Instead of unwrap()
use std::num::ParseIntError;
fn parse_and_double(input: &str) -> Result<i32, ParseIntError> {
let number: i32 = input.parse()?;
Ok(number * 2)
}
fn main() {
match parse_and_double("21") {
Ok(value) => println!("Doubled: {value}"),
Err(e) => println!("Failed to parse: {e}"),
}
match parse_and_double("not a number") {
Ok(value) => println!("Doubled: {value}"),
Err(e) => println!("Failed to parse: {e}"),
}
}
Output:
Doubled: 42
Failed to parse: invalid digit found in string
parse_and_double returns a Result instead of panicking. When input.parse() fails, the ? operator immediately returns the Err from the whole function; when it succeeds, ? unwraps the Ok value and execution continues. The caller in main decides what to do with each outcome via match — nothing panics, even for bad input.
Example 3: Iterators and Derived Traits Over Manual Loops
#[derive(Debug)]
struct Product {
name: String,
price_cents: u32,
}
fn total_price(products: &[Product]) -> u32 {
products.iter().map(|p| p.price_cents).sum()
}
fn expensive_products(products: &[Product], threshold_cents: u32) -> Vec<&Product> {
products
.iter()
.filter(|p| p.price_cents > threshold_cents)
.collect()
}
fn main() {
let products = vec![
Product { name: String::from("Mouse"), price_cents: 1999 },
Product { name: String::from("Keyboard"), price_cents: 4999 },
Product { name: String::from("Monitor"), price_cents: 15999 },
];
println!("Total: {} cents", total_price(&products));
for product in expensive_products(&products, 3000) {
println!("Pricey: {product:?}");
}
}
Output:
Total: 22997 cents
Pricey: Product { name: "Keyboard", price_cents: 4999 }
Pricey: Product { name: "Monitor", price_cents: 15999 }
#[derive(Debug)] generates a readable {:?} format for Product for free, instead of hand-writing a Display/formatting implementation. Both helper functions use iterator adapters — map/sum and filter/collect — instead of a manual loop with an index and an accumulator variable, which is shorter, harder to get off-by-one wrong, and just as fast.
How It Works Step by Step
Each example above relies on a compile-time or zero-cost mechanism, not a runtime trick:
In Example 1, the compiler checks at the call site that &names can be coerced to &[String] and that no conflicting mutable borrow of names exists while the immutable borrow is alive. Once print_names returns, the borrow ends, and names is fully usable again — nothing was moved or copied.
In Example 2, ? desugars roughly to “if this expression is Err(e), return Err(e.into()) from the enclosing function right now; otherwise, unwrap the Ok value and keep going.” Because parse_and_double‘s return type is Result<i32, ParseIntError> — the same error type parse() produces — no conversion is needed here, but ? will call From::from automatically when the error types differ, as long as a conversion exists.
In Example 3, products.iter().map(...).sum() builds a lazy pipeline: nothing runs until .sum() pulls values through it one at a time. The compiler monomorphizes and inlines this chain into machine code equivalent to a hand-written loop — this is Rust’s “zero-cost abstraction” promise: the higher-level iterator code costs nothing extra at runtime compared to the loop you’d write by hand.
Common Mistakes
Mistake 1: Cloning to Sidestep a Borrow, Not Because You Need a Copy
fn print_all(items: &Vec<String>) {
for item in items {
let copy = item.clone();
println!("{copy}");
}
}
fn main() {
let items = vec![String::from("a"), String::from("b")];
print_all(&items);
}
Output:
a
b
This compiles and runs fine, which is exactly why it’s easy to miss: item.clone() allocates a brand-new String on the heap for every element, purely to print it, when a reference would have worked just as well. It’s a performance footgun, not a compile error — and it’s extremely common in code written by people who reach for .clone() whenever the borrow checker complains, without asking whether a borrow would have satisfied it instead.
fn print_all(items: &[String]) {
for item in items {
println!("{item}");
}
}
fn main() {
let items = vec![String::from("a"), String::from("b")];
print_all(&items);
}
Output:
a
b
The corrected version borrows each String as &String during iteration and never allocates. Reach for .clone() only when you genuinely need an independent, owned copy — for example, to store a value in two places that will be mutated separately.
Mistake 2: Reaching for .unwrap() on Data You Don’t Control
fn main() {
let numbers = vec!["10", "20", "oops", "40"];
let sum: i32 = numbers.iter().map(|n| n.parse::<i32>().unwrap()).sum();
println!("Sum: {sum}");
}
Output:
(no output — the program panics before the final println! runs)
This compiles cleanly, because .unwrap() is valid on any Result. The problem shows up only at runtime: the moment .parse::<i32>() hits "oops", it returns Err, .unwrap() panics, and the whole program crashes before println! ever runs — even though three of the four strings were perfectly parseable. Treating .unwrap() as the default way to “get the value out” of a Result turns any bad input into a crash.
fn main() {
let numbers = vec!["10", "20", "oops", "40"];
let mut sum = 0;
for n in &numbers {
match n.parse::<i32>() {
Ok(value) => sum += value,
Err(_) => println!("Skipping invalid number: {n}"),
}
}
println!("Sum: {sum}");
}
Output:
Skipping invalid number: oops
Sum: 70
The corrected version matches on the Result and decides what to do with each outcome — skip and report the bad entry, or add the parsed value to the running total. The program keeps running and produces a useful result instead of crashing on the first bad input.
Mistake 3: Holding a Borrow Across a Mutation
fn main() {
let mut scores = vec![10, 20, 30];
let first = &scores[0];
scores.push(40);
println!("First score: {first}");
}
This is a genuine borrow-checker violation, not just a style issue — it fails to compile with “cannot borrow scores as mutable because it is also borrowed as immutable.” first is a shared reference into scores, and its borrow lasts until its last use, which is the final println!. scores.push(40) needs a mutable borrow of scores to potentially reallocate the underlying buffer, and Rust refuses to let a mutable borrow exist while a shared borrow of the same data is still going to be used — if the push reallocates, first would point at freed memory.
fn main() {
let mut scores = vec![10, 20, 30];
let first = scores[0];
scores.push(40);
println!("First score: {first}");
}
Output:
First score: 10
i32 implements Copy, so let first = scores[0]; copies the value out instead of borrowing it. first is now a completely independent i32 with no relationship to scores, so mutating scores afterward is perfectly fine. Reaching for a copy of a small Copy type is often the right fix when you only need one value out of a collection, not a live view into it.
Best Practices
- Run
cargo fmtandcargo clippyas part of your normal workflow — clippy catches idiomatic issues, like the ones above, thatrustcitself won’t flag. - Accept the most general borrowed type your function needs (
&strover&String,&[T]over&Vec<T>) so callers with owned or borrowed data can both call it without extra work. - Use
Result<T, E>and the?operator for anything that can fail; reserve.unwrap()/.expect()for cases you’ve proven can’t fail, and prefer.expect("message")over a bare.unwrap()so a panic explains itself. - Model impossible states as impossible: use
Option,Result, and enums instead of sentinel values like-1or an empty string to mean “no value.” - Prefer iterator adapters (
map,filter,fold,sum) over manual index loops — they’re harder to get off-by-one wrong and compile down to equivalent machine code. - Treat
.clone()as a deliberate decision, not a reflex for silencing the borrow checker — first check whether restructuring scope or borrowing differently avoids the copy entirely. - Derive
Debug, and where appropriateClone,PartialEq, andDefault, instead of hand-writing boilerplate trait implementations. - Keep functions small with a single clear responsibility — it makes both borrow-checker errors and unit tests far easier to reason about.
- Write
#[test]functions next to the code they test, run them withcargo test, and treat compiler warnings as things to fix, not ignore. - Document public functions and types with
///doc comments socargo docproduces useful, browsable documentation.
Practice Exercises
- Take a function
fn sum_lengths(strings: Vec<String>) -> usizethat owns its inputVec, and rewrite its signature so it borrows instead, without changing its behavior. Confirm (by reasoning through it) that the caller can still use theVecafter calling your new version. - Write
fn safe_divide(a: f64, b: f64) -> Option<f64>that returnsNoneinstead of panicking whenbis zero. Useif letto print either the result or a friendly message for a few test inputs, including a division by zero. - Take a manual
forloop with an index variable that sums the squares of the numbers 1 through 10, and rewrite it as(1..=10).map(|n| n * n).sum(). Both versions should produce385.
Summary
- The compiler enforces memory and thread safety; best practices are about clarity, performance, and API ergonomics built on top of that guarantee.
- Borrow with
&str/&[T]when a function only needs to read data; take ownership only when you need to keep, mutate, or move it. - Use
Result/Optionwith the?operator to make failure explicit instead of reaching for.unwrap()by default. - Prefer iterator adapters over manual index loops for both safety and readability, at no runtime cost.
- Clone deliberately, not reflexively — a clone should be a conscious choice, not a fix for a borrow-checker error you didn’t fully diagnose.
- Make
cargo fmt,cargo clippy, andcargo testa routine part of writing Rust, not optional extras.
