Common Rust Pitfalls
Even experienced programmers run into the same Rust traps repeatedly. This page documents thirteen of the most common mistakes, explains exactly why each one is wrong, and shows the correct fix. Recognising these patterns early will save you hours of frustrating debugging.
1. Cloning Everywhere to Satisfy the Borrow Checker
When the borrow checker rejects code, the tempting quick fix is to add .clone()
until it compiles. This works, but it hides a design problem and can silently make
your program allocate far more than necessary.
// Pitfall — clone used to paper over an ownership problem
fn process_items(items: Vec<String>) -> Vec<String> {
let mut results = Vec::new();
for item in &items {
results.push(item.clone().to_uppercase()); // clone is unnecessary
}
results
// items is still owned but never used after this — wasteful to keep it
}// Fix — restructure ownership; no clone needed
fn process_items(items: Vec<String>) -> Vec<String> {
items.into_iter().map(|s| s.to_uppercase()).collect()
// into_iter() consumes the Vec, moving each String — zero copies
}
fn main() {
let items = vec![String::from("hello"), String::from("world")];
let out = process_items(items);
println!("{:?}", out); // ["HELLO", "WORLD"]
}["HELLO", "WORLD"]
2. Using .unwrap() in Production Code
unwrap() panics if the value is None or Err. In a production binary, a panic
unwinds the stack, prints a message with no useful context, and crashes the thread.
The error message gives the user (and you) no information about what went wrong or
where.
// Pitfall — panics with a useless backtrace in production
fn load_config(path: &str) -> Config {
let content = std::fs::read_to_string(path).unwrap();
serde_json::from_str(&content).unwrap()
}
// Panic message: "called Result::unwrap() on an Err value: Os { code: 2, ... }"// Fix — propagate errors with context
use anyhow::{Context, Result};
fn load_config(path: &str) -> Result<Config> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read config file '{}'", path))?;
serde_json::from_str(&content)
.context("config file contains invalid JSON")
}
// Error message: "failed to read config file 'app.json': No such file or directory"unwrap() and expect() for cases that are genuinely impossible — for example, parsing a hardcoded string literal that you control. Even then, prefer expect("reason this is unreachable")over unwrap() so the message is useful if you are ever wrong.3. Blocking Inside Async Code
Async runtimes like Tokio use a small pool of OS threads to drive many async tasks.
When you call a blocking function — such as std::thread::sleep, a synchronous
file read, or a CPU-intensive loop — from inside an async function, you block the
entire OS thread. All other tasks scheduled on that thread are frozen for the
duration.
// Pitfall — blocks the async runtime thread
use std::time::Duration;
async fn fetch_with_retry(url: &str) -> String {
for attempt in 0..3 {
if let Ok(body) = http_get(url).await {
return body;
}
std::thread::sleep(Duration::from_secs(1)); // BLOCKS the runtime thread!
}
String::new()
}// Fix — use the async-aware sleep
use tokio::time::{sleep, Duration};
async fn fetch_with_retry(url: &str) -> String {
for attempt in 0..3 {
if let Ok(body) = http_get(url).await {
return body;
}
sleep(Duration::from_secs(1)).await; // yields to the runtime
}
String::new()
}tokio::task::spawn_blocking to move the work onto a dedicated blocking thread pool rather than stalling the async executor.4. Forgetting to .join() Spawned Threads
When a JoinHandle is dropped without calling .join(), the thread is detached —
it continues running in the background. But if the main thread exits before the
spawned thread finishes, the spawned thread is silently killed mid-execution.
// Pitfall — handle is dropped, thread may be killed before finishing
fn main() {
std::thread::spawn(|| {
expensive_computation(); // may never complete!
});
// JoinHandle dropped here — no guarantee the thread finishes
println!("main done");
}// Fix — always join handles you care about
fn main() {
let handle = std::thread::spawn(|| {
expensive_computation();
println!("thread done");
});
println!("main: waiting for thread...");
handle.join().expect("thread panicked");
println!("main done");
}main: waiting for thread... thread done main done
Vec<JoinHandle<_>> and join them all at the end if you spawn many threads in a loop.5. Integer Overflow in Release Mode
In debug builds, Rust inserts overflow checks and panics on overflow. In release builds, those checks are removed and integer arithmetic wraps silently (two's complement wrapping). Code that panics visibly in development can silently produce wrong results in production.
// Pitfall — works in debug, wraps silently in release
fn total_bytes(sizes: &[u8]) -> u8 {
let mut total: u8 = 0;
for &s in sizes {
total += s; // wraps at 255 in release mode with no warning or panic
}
total
}
fn main() {
// [200, 100] sums to 300, which overflows u8 (max 255)
println!("{}", total_bytes(&[200, 100])); // debug: panic; release: 44
}// Fix — use checked arithmetic, saturating arithmetic, or a larger type
fn total_bytes_checked(sizes: &[u8]) -> Option<u8> {
let mut total: u8 = 0;
for &s in sizes {
total = total.checked_add(s)?; // returns None on overflow
}
Some(total)
}
fn total_bytes_wide(sizes: &[u8]) -> u32 {
sizes.iter().map(|&s| s as u32).sum() // use a type that cannot overflow
}
fn main() {
println!("{:?}", total_bytes_checked(&[200, 100])); // None
println!("{}", total_bytes_wide(&[200, 100])); // 300
}None 300
6. Byte-Indexing a String
Rust's String is UTF-8 encoded. A single Unicode character can occupy 1 to 4
bytes. Indexing a String with a byte offset (e.g. my_string[2]) is a compile
error. Indexing with a range (e.g. &my_string[0..2]) compiles but panics at
runtime if the byte boundary falls in the middle of a multi-byte character.
// Pitfall — panics at runtime with a non-ASCII string
fn first_two_bytes(s: &str) -> &str {
&s[0..2] // panics if byte 2 is not a character boundary
}
fn main() {
println!("{}", first_two_bytes("hello")); // OK — 'h' and 'e' are 1 byte each
println!("{}", first_two_bytes("こんにちは")); // PANIC — 'こ' is 3 bytes
}// Fix — use char-aware methods
fn first_two_chars(s: &str) -> String {
s.chars().take(2).collect()
}
fn nth_char(s: &str, n: usize) -> Option<char> {
s.chars().nth(n)
}
fn main() {
println!("{}", first_two_chars("hello")); // he
println!("{}", first_two_chars("こんにちは")); // こん
println!("{:?}", nth_char("Rust", 1)); // Some('u')
}he
こん
Some('u')7. Misunderstanding .iter() vs .into_iter() vs .iter_mut()
These three methods produce iterators with different item types, and picking the wrong one leads to confusing type errors or unexpected moves of the collection.
Method | Item type | Collection afterwards |
|---|---|---|
.iter() | &T — immutable reference | Unchanged, still usable |
.iter_mut() | &mut T — mutable reference | Unchanged, still usable |
.into_iter() | T — owned value | Consumed — cannot use it again |
// Pitfall — wrong iterator type causes a type mismatch
let nums = vec![1, 2, 3];
let doubled: Vec<i32> = nums.iter().map(|x| x * 2).collect();
// ^ x is &i32, not i32 — works here
// but causes errors when you need ownership
// Also a pitfall — accidentally consuming the vec
let words = vec![String::from("a"), String::from("b")];
for w in words.into_iter() { println!("{}", w); }
// println!("{:?}", words); // ERROR — words was moved into the iterator// Fix — choose the right method for your needs
fn main() {
let nums = vec![1_i32, 2, 3];
// iter() — borrow, dereference inside the closure
let doubled: Vec<i32> = nums.iter().map(|&x| x * 2).collect();
// iter_mut() — mutate in place
let mut data = vec![1, 2, 3];
data.iter_mut().for_each(|x| *x *= 10);
println!("{:?}", data); // [10, 20, 30]
// into_iter() — when you want to consume and transform
let words = vec![String::from("hello"), String::from("world")];
let upper: Vec<String> = words.into_iter().map(|s| s.to_uppercase()).collect();
println!("{:?}", upper); // ["HELLO", "WORLD"]
}[10, 20, 30] ["HELLO", "WORLD"]
8. Creating Reference Cycles with Rc<T>
Rc<T> uses reference counting to track ownership. When two Rc values point to
each other — directly or through RefCell — they form a cycle. Neither reference
count ever drops to zero, so the memory is never freed. This is a memory leak.
// Pitfall — reference cycle: a → b → a, neither is ever freed
use std::rc::Rc;
use std::cell::RefCell;
#[derive(Debug)]
struct Node {
next: Option<Rc<RefCell<Node>>>,
}
fn main() {
let a = Rc::new(RefCell::new(Node { next: None }));
let b = Rc::new(RefCell::new(Node { next: Some(Rc::clone(&a)) }));
// Create the cycle: a now points to b
a.borrow_mut().next = Some(Rc::clone(&b));
// When main() ends, a and b go out of scope but their ref counts are 1, not 0.
// Memory is leaked. Rust's drop checker cannot help here.
println!("Rc count a: {}", Rc::strong_count(&a)); // 2 — never drops to 0
}// Fix — break the cycle with Weak<T> for back-references
use std::rc::{Rc, Weak};
use std::cell::RefCell;
#[derive(Debug)]
struct Node {
children: Vec<Rc<RefCell<Node>>>,
parent: Option<Weak<RefCell<Node>>>, // Weak — does not contribute to ref count
}
fn main() {
let parent = Rc::new(RefCell::new(Node { children: vec![], parent: None }));
let child = Rc::new(RefCell::new(Node {
children: vec![],
parent: Some(Rc::downgrade(&parent)), // Weak reference — no cycle
}));
parent.borrow_mut().children.push(Rc::clone(&child));
// Both are freed correctly when they go out of scope
println!("parent strong count: {}", Rc::strong_count(&parent)); // 1
}9. Holding a Mutex Lock Across an .await Point
A std::sync::Mutex guard (MutexGuard) is held across an .await point, the
async task suspends while still holding the lock. No other task — including one on the
same thread — can acquire the mutex, causing a deadlock or severe contention.
// Pitfall — lock held across an await point
use std::sync::Mutex;
async fn update_counter(counter: &Mutex<u32>) {
let mut guard = counter.lock().unwrap();
*guard += 1;
some_async_operation().await; // <-- lock still held here!
println!("counter: {}", *guard);
// guard drops here — but the await above already caused the problem
}// Fix — drop the lock before awaiting, or use tokio::sync::Mutex
use tokio::sync::Mutex; // async-aware mutex
async fn update_counter(counter: &Mutex<u32>) {
{
let mut guard = counter.lock().await; // async lock — does not block threads
*guard += 1;
} // guard dropped here — lock released before the await
some_async_operation().await; // no lock held
}tokio::sync::Mutex, avoid holding a lock for longer than necessary. Long lock hold times create contention. Do the minimum work inside the lock and release it as soon as possible.10. Storing a Reference in a Struct Without Lifetime Annotations
If a struct stores a reference, the compiler needs to know how long that reference must remain valid relative to the struct itself. Without a lifetime annotation, the compiler rejects the code with a confusing error about missing lifetime specifiers.
// Pitfall — missing lifetime annotation
struct Config {
name: &str, // ERROR: expected named lifetime parameter
}
impl Config {
fn new(name: &str) -> Self {
Config { name }
}
}// Fix — annotate the lifetime
struct Config<'a> {
name: &'a str, // the reference must live at least as long as Config
}
impl<'a> Config<'a> {
fn new(name: &'a str) -> Self {
Config { name }
}
}
fn main() {
let name = String::from("production");
let cfg = Config::new(&name);
println!("config: {}", cfg.name);
}config: production
String instead of&str. Owned types eliminate the need for lifetime annotations and are often the right choice unless you have a strong performance reason to avoid the allocation.11. Thinking clone() on Rc/Arc Copies the Data
Rc<T>::clone() and Arc<T>::clone() do not copy the inner value. They
increment the reference count and return a new smart pointer that points to the same
allocation. This is intentional — it is how shared ownership works — but it surprises
programmers who expect clone to mean "deep copy".
// Pitfall — misunderstanding what clone does here
use std::sync::Arc;
fn main() {
let data = Arc::new(vec![1, 2, 3]);
let copy = data.clone(); // does NOT clone the Vec — clones the Arc pointer
// Both 'data' and 'copy' point to the SAME Vec
println!("same address: {}", Arc::ptr_eq(&data, ©)); // true
println!("ref count: {}", Arc::strong_count(&data)); // 2
}same address: true ref count: 2
// Fix — if you need an independent copy, dereference and clone the inner value
use std::sync::Arc;
fn main() {
let data = Arc::new(vec![1, 2, 3]);
// Clone the inner Vec — produces an independent allocation
let independent_copy: Vec<i32> = (*data).clone();
println!("same address: {}", std::ptr::eq(
data.as_ptr(),
independent_copy.as_ptr(),
)); // false — different allocations
}12. Ignoring Errors with let _ = or .ok()
Both let _ = fallible_fn() and fallible_fn().ok() silently discard the error.
The code compiles cleanly and the failure is invisible at runtime. This pattern is
appropriate only for errors that are genuinely unrecoverable and already logged
internally — in all other cases it hides bugs.
// Pitfall — errors silently discarded
fn persist_session(user_id: u64, token: &str) {
let _ = write_to_db(user_id, token); // did it succeed? we will never know
let _ = write_audit_log(user_id); // silent failure here is a security hole
}// Fix — handle or explicitly propagate each error
use anyhow::Result;
fn persist_session(user_id: u64, token: &str) -> Result<()> {
write_to_db(user_id, token)
.context("failed to persist session token")?;
write_audit_log(user_id)
.context("failed to write audit log")?;
Ok(())
}
// Or at least log the failure if you cannot propagate it
fn persist_session_best_effort(user_id: u64, token: &str) {
if let Err(e) = write_to_db(user_id, token) {
eprintln!("warning: session persist failed for {}: {}", user_id, e);
}
}13. Confusing Send and Sync Requirements Across Threads
Rust enforces thread safety through two auto-traits:
Send— a type can be moved to another threadSync— a type can be shared by reference (&T) across threads (i.e.&TisSend)
Types like Rc<T>, RefCell<T>, and raw pointers are not Send or Sync.
Trying to send them across a thread boundary produces a compile error. The fix is
almost always to replace them with their thread-safe equivalents.
// Pitfall — Rc is not Send; the compiler rejects this
use std::rc::Rc;
use std::thread;
fn main() {
let data = Rc::new(42);
thread::spawn(move || {
println!("{}", data); // ERROR: Rc<i32> cannot be sent between threads safely
});
}// Fix — use Arc (atomic reference count) for shared ownership across threads
use std::sync::Arc;
use std::thread;
fn main() {
let data = Arc::new(42);
let data_clone = Arc::clone(&data);
let handle = thread::spawn(move || {
println!("thread: {}", data_clone); // Arc<i32> is Send + Sync
});
handle.join().unwrap();
println!("main: {}", data);
}thread: 42 main: 42
Not thread-safe | Thread-safe replacement | Why |
|---|---|---|
Rc<T> | Arc<T> | Arc uses atomic operations for the reference count |
RefCell<T> | Mutex<T> or RwLock<T> | Mutex/RwLock synchronise access across threads |
Cell<T> | Atomic types (AtomicU32, etc.) | Atomics provide lock-free thread-safe mutation |
*mut T (raw pointer) | Arc<Mutex<T>> | Wrap in safe types before crossing thread boundaries |