RustCommon Pitfalls

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.

RUST
// 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
}

RUST
// 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"]
Tip
When you feel the urge to clone, ask: who needs to own this value, and for how long? Often the answer reveals that you can pass a reference or restructure the data flow to avoid the copy entirely.
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.

RUST
// 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, ... }"

RUST
// 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"
Note
Reserve 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.

RUST
// 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()
}

RUST
// 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()
}
Warning
For CPU-intensive work inside an async context, usetokio::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.

RUST
// 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");
}

RUST
// 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
Tip
Collect handles into a 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.

RUST
// 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
}

RUST
// 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.

RUST
// 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
}

RUST
// 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

RUST
// 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

RUST
// 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.

RUST
// 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
}

RUST
// 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.

RUST
// 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
}

RUST
// 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
}
Warning
Even with 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.

RUST
// Pitfall — missing lifetime annotation
struct Config {
    name: &str,    // ERROR: expected named lifetime parameter
}

impl Config {
    fn new(name: &str) -> Self {
        Config { name }
    }
}

RUST
// 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
Tip
A common alternative is to store an owned 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".

RUST
// 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, &copy)); // true
    println!("ref count:    {}", Arc::strong_count(&data));  // 2
}
same address: true
ref count:    2

RUST
// 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.

RUST
// 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
}

RUST
// 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 thread
  • Sync — a type can be shared by reference (&T) across threads (i.e. &T is Send)

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.

RUST
// 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
    });
}

RUST
// 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

Success
Most of these pitfalls follow a common theme: Rust's type system and compiler will catch the worst mistakes at compile time. When you see a confusing error, it usually points directly to one of these patterns. Learning to read the error message carefully — especially the "help" suggestions — is one of the most effective debugging skills you can develop as a Rust programmer.