Common Rust Mistakes

Even experienced developers hit a wall the first time Rust’s compiler refuses to build code that would run just fine in Python, JavaScript, or C++. That wall isn’t a bug — it’s Rust’s ownership and borrowing system doing exactly what it was designed to do: catching memory-safety and logic errors before your program ever runs. This lesson walks through the mistakes almost every Rust learner makes, why the compiler rejects them (or why they slip through as runtime panics), and the idiomatic fix for each one.

Overview: Why These Mistakes Happen

Most “common Rust mistakes” fall into three buckets, and knowing which bucket a mistake belongs to tells you how to think about the fix.

Ownership and move errors. Every value in Rust has exactly one owner. When you assign a non-Copy value like a String or a Vec<T> to another variable, or pass it into a function by value, ownership moves — the original binding becomes invalid, and the compiler refuses to let you use it again. This is different from copying a reference (as in Python or JavaScript) and different from deep-copying the whole value automatically; Rust moves by default so that two variables never both believe they own — and could each free — the same heap allocation.

Borrowing conflicts. Instead of moving a value, you can borrow it with a reference: &T for read-only access, &mut T for exclusive read-write access. The rule the borrow checker enforces is simple to state and easy to violate in practice: at any point in the program, a value may have either one mutable reference or any number of immutable references — never both at once. The checker tracks how long a reference is actually used, not just where it’s declared, so two borrows that look like they overlap on paper are sometimes allowed because their real uses don’t overlap — this is why the fix for a borrow error is often just reordering a few lines.

Runtime footguns. Not every mistake is caught at compile time. Indexing a slice or Vec out of bounds compiles perfectly and panics while running. Calling .unwrap() on a None or an Err compiles perfectly and panics while running. These are the mistakes the type system can’t save you from — only good habits, like preferring match, if let, and safe accessors such as .get(), can.

Because the compiler enforces the first two buckets so aggressively, a Rust program that compiles is already free of an entire class of bugs — dangling pointers, use-after-free, double-free, and data races — that require careful discipline (or a garbage collector) in other languages. The trade-off is a steeper learning curve while you build the habits below.

Quick Reference: Mistake Patterns

Pattern What Happens Typical Fix
Using a variable after moving it Compile error (E0382) Borrow with &, or call .clone() if you truly need two owners
Mutable borrow while an immutable borrow is still in use Compile error (E0502) Shrink the immutable borrow’s scope or reorder the code
Indexing past the end of a slice or Vec Runtime panic Use .get(i) and handle the Option
Forgetting mut on a binding you reassign Compile error (E0384) Add mut to the let
Calling .unwrap() on an uncertain Option/Result Runtime panic Use match, if let, or combinators

Examples

Each example below shows the idiomatic pattern that sidesteps a mistake covered later in this lesson.

Example 1: Borrow a String Instead of Taking Ownership

fn print_length(text: &str) {
    println!("Length: {}", text.len());
}

fn main() {
    let name = String::from("Ferris the crab");
    print_length(&name);
    println!("Still usable: {}", name);
}

Output:

Length: 15
Still usable: Ferris the crab

print_length takes a &str instead of an owned String, so calling it only borrows name for the duration of the call. Ownership never leaves main, so name is perfectly usable on the next line. A &String automatically coerces to &str at the call site (deref coercion), so this function also accepts string literals directly — one signature, two kinds of callers.

Example 2: Handle Parse Failures Instead of Unwrapping

fn parse_number(input: &str) -> Option<i32> {
    match input.parse::<i32>() {
        Ok(n) => Some(n),
        Err(_) => None,
    }
}

fn main() {
    let inputs = ["42", "not_a_number", "7"];
    for input in inputs.iter() {
        match parse_number(input) {
            Some(n) => println!("Parsed: {}", n),
            None => println!("Failed to parse '{}'", input),
        }
    }
}

Output:

Parsed: 42
Failed to parse 'not_a_number'
Parsed: 7

str::parse returns a Result<i32, ParseIntError>, which this function converts into a plain Option<i32> with a match. Nothing here can panic: a malformed input becomes None and is handled like any other value, instead of crashing the program the way input.parse::<i32>().unwrap() would on the second input.

Example 3: Build a New Collection Instead of Mutating While Iterating

struct Task {
    name: String,
    done: bool,
}

fn main() {
    let tasks = vec![
        Task { name: String::from("Write lesson"), done: true },
        Task { name: String::from("Review code"), done: false },
        Task { name: String::from("Publish"), done: false },
    ];

    let pending: Vec<&str> = tasks
        .iter()
        .filter(|t| !t.done)
        .map(|t| t.name.as_str())
        .collect();

    for name in &pending {
        println!("Pending: {}", name);
    }
}

Output:

Pending: Review code
Pending: Publish

Trying to remove items from tasks while iterating over it (with something like a for loop that calls tasks.remove(i)) is a classic borrow conflict: the loop holds an immutable borrow for iteration while the removal needs a mutable one. Building a fresh Vec with .filter() and .collect() sidesteps the conflict entirely and is the idiomatic Rust style besides.

How the Borrow Checker Verifies Your Code

The borrow checker runs as a distinct pass after your code is parsed and type-checked. For every reference, it computes the span of code where that reference is actually read — not just where the variable is declared, but every later point where it’s dereferenced or passed along. Walk through the rejected version of the Vec example from the mistakes below:

  1. let first = &v[0]; creates an immutable borrow of v. The checker notes that first will be read later, at the println!.
  2. v.push(4) needs a mutable borrow of v — pushing can reallocate the vector’s backing buffer, which would invalidate any existing reference into it.
  3. Because the checker already knows first is read after this line, the immutable borrow’s lifetime overlaps with the attempted mutable borrow. That overlap is exactly what “one mutable reference or many immutable references, never both” forbids, so compilation stops with an error pointing at the push call.
  4. If you take the borrow after the push instead — as in the corrected version — the two borrows no longer overlap and the same code compiles. The rule is about overlapping use, not overlapping syntax.

The reallocation detail matters because Vec<T> stores its elements in one contiguous heap buffer. When it grows past its capacity, Rust allocates a new, larger buffer, copies the existing elements over, and frees the old buffer. A reference taken before that reallocation would point at freed memory if the compiler allowed it to survive — the borrow checker exists precisely to make that impossible, at compile time, with zero runtime cost.

Common Mistakes

Mistake 1: Using a Value After It’s Moved

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
    println!("{}", s1);
}

Rust rejects this with an error like error[E0382]: borrow of moved value: `s1`. Assigning s1 to s2 moved the String‘s ownership (and the heap buffer it points to) to s2; s1 is no longer valid, so using it afterward is a compile error rather than a silent bug. Note that if s1 were an i32 instead of a String, this exact code would compile fine — integers implement Copy, so assignment duplicates the value instead of moving it. It’s specifically non-Copy types like String and Vec<T> where this bites. If you genuinely need two independent, owned copies, call .clone(), which allocates a second heap buffer and copies the bytes into it:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();
    println!("{} {}", s1, s2);
}

Output:

hello hello

Mistake 2: Mutably Borrowing While an Immutable Borrow Is Still Alive

fn main() {
    let mut v = vec![1, 2, 3];
    let first = &v[0];
    v.push(4);
    println!("{}", first);
}

This fails with error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable. As traced in the section above, first is still in use at the final println!, so its immutable borrow is alive across the v.push(4) call, which needs exclusive mutable access. The fix is to reorder so the borrows don’t overlap:

fn main() {
    let mut v = vec![1, 2, 3];
    v.push(4);
    let first = &v[0];
    println!("{}", first);
}

Output:

1

Mistake 3: Indexing Past the End of a Collection

fn main() {
    let v = vec![1, 2, 3];
    println!("{}", v[3]);
}

Unlike the previous two mistakes, this one compiles without complaint — the compiler can’t know at compile time whether a runtime index is in bounds. It fails only when the program actually runs, with a panic like thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 3'. Bracket indexing (v[3]) is a promise to the compiler that the index is valid; when you can’t guarantee that, use .get(), which returns an Option<&T> instead of panicking:

fn main() {
    let v = vec![1, 2, 3];
    match v.get(3) {
        Some(value) => println!("Value: {}", value),
        None => println!("Index out of bounds"),
    }
}

Output:

Index out of bounds

Mistake 4: Forgetting mut on a Binding You Reassign

fn main() {
    let count = 0;
    count += 1;
    println!("{}", count);
}

Bindings created with let are immutable by default — a deliberate design choice so that, when you read code, a plain let is a guarantee the value never changes underneath you. Trying to reassign count here fails with error[E0384]: cannot assign twice to immutable variable `count`. The fix is one keyword:

fn main() {
    let mut count = 0;
    count += 1;
    println!("{}", count);
}

Output:

1

Best Practices

  • Prefer borrowing (&T or &mut T) over cloning; reach for .clone() only when you genuinely need a second independent owner, and treat frequent cloning as a signal to reconsider the data flow.
  • Take &str parameters for read-only string arguments so callers can pass either a String (via deref coercion) or a string literal.
  • Reserve .unwrap() for cases where a None/Err truly cannot happen, and prefer .expect("message") so a panic explains itself if your assumption turns out to be wrong.
  • Use .get(index) instead of bracket indexing whenever the index isn’t provably in range.
  • Let the compiler’s error messages guide you — rustc’s “consider borrowing here” style suggestions are usually the correct, idiomatic fix, not a workaround.
  • Run cargo clippy regularly; it catches needless clones, indexing that could use .get(), and dozens of other patterns before they become mistakes.
  • When the borrow checker rejects code you believe is safe, first look for a way to shrink a borrow’s scope (reordering statements, a nested block, an early return) before reaching for Rc<RefCell<T>> or unsafe.

Practice Exercises

  1. Write a function that takes a &Vec<i32> and returns the largest value as an Option<i32> (returning None for an empty vector), without calling .unwrap() anywhere.
  2. Take the “mutable while borrowed” example from this lesson and rewrite it three different ways that all compile — for example, by reordering the statements, by cloning the borrowed value first, or by reading the element by index instead of by reference.
  3. Write a function ownership_demo that takes a String, prints it, and returns the same String back to the caller. Call it from main, store the returned value, and print it again to confirm the caller still owns a valid string afterward.

Summary

  • Rust catches ownership and borrowing mistakes at compile time instead of leaving them as runtime bugs.
  • Assigning or passing a non-Copy value (String, Vec<T>, most structs) moves it; the old binding becomes unusable afterward.
  • At any point, a value may have one mutable reference or any number of immutable references — never both at once.
  • Some mistakes, like out-of-bounds indexing and .unwrap() on None/Err, compile fine and panic at runtime; only careful use of Option/Result APIs prevents them.
  • .clone(), .get(), match, and if let are the main tools for turning a rejected or panicking program into a correct one.
  • cargo clippy and the compiler’s own suggestions are reliable guides toward the idiomatic fix.