Common Anti-Patterns in Rust and How to Avoid Them

Avoid Rust anti-patterns by handling errors with Result, passing references instead of cloning, and minimizing unsafe code.

The crash at line 42

You finish porting a working Python script to Rust. The logic matches. The tests pass locally. Then you run it against real data and the process crashes with a stack trace pointing to line 42. You check the code. It is just unwrap(). You fix that, but now the program is eating two hundred megabytes of RAM for a simple text parser. You check the code again. You cloned every string to avoid lifetime errors. You patch that, and suddenly the borrow checker is yelling at you for trying to mutate a vector while iterating it. You throw an unsafe block around the loop to make it compile. It works. Until it does not.

These three moves are the most common anti-patterns in Rust. They are not mistakes born from ignorance. They are habits carried over from languages that handle memory and errors differently. Rust forces you to confront those habits at compile time. Fighting the compiler usually means fighting your own assumptions.

Guardrails, not roadblocks

Think of Rust's type system like a strict building inspector. In Python or JavaScript, you can nail a board to a wall, hang a heavy shelf, and hope the drywall holds. If it falls, you catch it or live with the mess. Rust makes you show the inspector the load-bearing calculations before you even pick up a hammer. The anti-patterns happen when you try to bypass the inspector, use cheap materials to avoid paperwork, or pretend the math works out. The language gives you better tools. You just have to stop reaching for the ones that feel familiar.

The unwrap() crutch

unwrap() is the fastest way to get a value out of a Result or Option. It is also the fastest way to crash your program. When you call it on an Err or None, Rust panics. The process terminates. No graceful degradation. No error message for the user. Just a stack trace.

New Rust developers reach for unwrap() because it mirrors how other languages handle probable success scenarios. You read a config file. You parse a JSON payload. You assume success and ignore the failure path to keep the code short.

/// Reads configuration from a file and returns the parsed settings.
fn load_config() -> String {
    // unwrap() panics if the file is missing or unreadable.
    // This turns a recoverable I/O error into a hard crash.
    std::fs::read_to_string("config.json").unwrap()
}

The compiler will not stop you. unwrap() is a safe function. The panic happens at runtime. When it does, you lose the context of what went wrong. Was the path wrong? Did the disk fill up? Was the permission denied? The panic message tells you the function failed, but not why.

Replace unwrap() with the ? operator for functions that return Result or Option. The ? operator propagates the error up the call stack. It keeps your code flat and forces the caller to decide how to handle the failure. If you need to handle the error locally, use match or if let. If you absolutely must panic during development, use expect() with a clear message. The community convention favors expect() over unwrap() because it documents the assumption right at the crash site.

/// Reads configuration and propagates I/O errors to the caller.
fn load_config() -> Result<String, std::io::Error> {
    // The ? operator unwraps the Ok value or returns the Err early.
    // This keeps error handling explicit without nested match blocks.
    std::fs::read_to_string("config.json")?
}

When you propagate errors, you are not ignoring them. You are routing them to a place that can actually do something useful. Log it. Retry it. Show a user-friendly message. The ? operator makes that routing automatic.

Stop treating panics as acceptable error handling. Route failures where they belong.

Cloning to escape the borrow checker

Rust's ownership model tracks who holds data and who is borrowing it. When the compiler complains about lifetimes or mutable borrows, the instinctive reaction is often to clone the data. If you own a copy, you do not have to worry about who else is looking at the original. The error disappears. The code compiles.

The cost is memory and CPU. Cloning a String allocates new heap memory and copies every byte. Cloning a Vec does the same. Do it inside a loop, and you are thrashing the allocator. Do it with large structs, and you are duplicating state that should be shared.

/// Processes a list of records by cloning each one unnecessarily.
fn process_records(records: Vec<String>) -> Vec<String> {
    let mut results = Vec::new();
    for record in records {
        // Cloning creates a new heap allocation for every iteration.
        // This defeats the purpose of zero-cost abstractions.
        let processed = record.clone();
        results.push(processed);
    }
    results
}

The compiler error you are usually trying to silence is E0502 (cannot borrow as mutable because it is also borrowed as immutable) or E0382 (use of moved value). Cloning masks the real problem. You are trying to mutate a collection while holding a reference to it, or you are consuming a value you still need later.

The fix is almost always references. Pass &T instead of T. Use slices &[T] instead of Vec<T>. Let the borrow checker track the temporary access. If you need to mutate a collection while iterating, split the operation. Collect indices first, then mutate. Or use iterators that handle the borrowing safely.

/// Processes records by borrowing instead of cloning.
fn process_records(records: &[String]) -> Vec<String> {
    // Borrowing the slice avoids copying the underlying data.
    // The borrow checker verifies the references don't outlive the data.
    records.iter().map(|record| format!("processed: {}", record)).collect()
}

When you pass references, you are telling the compiler exactly how long you need the data. The compiler enforces that contract. If you need a type that can hold either a borrowed reference or an owned value, reach for Cow<'_, T>. It clones only when mutation is required. Otherwise, it borrows.

Trust the borrow checker. It usually has a point.

Using unsafe to silence the compiler

unsafe is not a performance keyword. It is not a make it compile button. It is a contract. When you wrap code in unsafe { }, you are telling the compiler that you have manually verified the code does not violate Rust's safety guarantees. You will not dereference null pointers. You will not cause data races. You will not break aliasing rules.

The compiler stops checking those specific rules inside the block. It also stops checking them for any code that calls into the block. If you get the contract wrong, you get undefined behavior. The program might crash. It might silently corrupt memory. It might work perfectly on your machine and explode in production.

/// Attempts to access an array index without bounds checking.
fn get_element(data: &[i32], index: usize) -> i32 {
    // Converting a slice to a raw pointer bypasses bounds checks.
    // Accessing an out-of-bounds index causes undefined behavior.
    let ptr = data.as_ptr();
    unsafe { *ptr.add(index) }
}

New developers use unsafe when they hit a wall with lifetimes, interior mutability, or FFI. They assume the compiler is being overly restrictive. The compiler is actually protecting you from bugs that are nearly impossible to debug once they escape.

The fix is to step back and find the safe abstraction that matches your intent. Need interior mutability? Use RefCell<T> for single-threaded code or Mutex<T> for multi-threaded code. Need to share ownership? Use Rc<T> or Arc<T>. Need to interact with C libraries? Use unsafe only at the exact boundary where you cross into foreign code, and wrap it in a safe API immediately.

/// Safely accesses an array element with bounds checking.
fn get_element(data: &[i32], index: usize) -> Option<i32> {
    // The get() method returns None if the index is out of bounds.
    // This keeps memory safety guarantees intact without unsafe.
    data.get(index).copied()
}

When you must use unsafe, keep the block as small as possible. The community calls this the minimum unsafe surface rule. Wrap the raw pointer dereference, not the entire function. Document every invariant you are upholding in a // SAFETY: comment. If you cannot write the proof, you do not have a safe wrapper.

Treat the // SAFETY: comment as a legal contract. If you can't write it, you don't have one.

Realistic scenario: parsing a config file

Consider a program that reads a configuration file, parses key-value pairs, and caches them for fast lookup. A developer fighting the anti-patterns might write it like this.

/// Loads and parses a configuration file into a cache.
fn build_cache(path: &str) -> std::collections::HashMap<String, String> {
    // Reading the file and unwrapping hides I/O failures.
    let content = std::fs::read_to_string(path).unwrap();
    let mut cache = std::collections::HashMap::new();
    
    for line in content.lines() {
        // Cloning the line string allocates unnecessarily.
        let line_copy = line.to_string();
        let parts: Vec<&str> = line_copy.splitn(2, '=').collect();
        
        // Indexing without bounds checking panics on malformed lines.
        let key = parts[0];
        let value = parts[1];
        
        cache.insert(key.to_string(), value.to_string());
    }
    cache
}

This function panics on missing files, panics on malformed lines, and allocates memory for every single line. The compiler allows it because every operation is technically safe. The runtime is where it breaks.

Rewrite it to handle errors explicitly, borrow data where possible, and use safe indexing.

/// Loads and parses a configuration file into a cache.
fn build_cache(path: &str) -> Result<std::collections::HashMap<String, String>, String> {
    // Propagate I/O errors with a descriptive message.
    let content = std::fs::read_to_string(path)
        .map_err(|e| format!("Failed to read config: {}", e))?;
    let mut cache = std::collections::HashMap::new();
    
    for line in content.lines() {
        // Split directly on the borrowed line to avoid cloning.
        let mut parts = line.splitn(2, '=');
        
        // Use next() to safely extract keys and values.
        let key = parts.next().ok_or("Missing key")?;
        let value = parts.next().ok_or("Missing value")?;
        
        // Insert trimmed strings to handle whitespace gracefully.
        cache.insert(key.trim().to_string(), value.trim().to_string());
    }
    Ok(cache)
}

The rewritten version allocates only for the final cache entries. It returns clear errors instead of panicking. It borrows the line data during parsing. The borrow checker is satisfied because the references are dropped before the function returns. The code is slower to write initially, but it survives real-world input without crashing.

Choosing the right tool

Rust gives you multiple paths for almost every problem. Picking the wrong one creates technical debt that compounds quickly. Use ? and match when you want to handle errors explicitly and keep your program running. Use expect() with a descriptive message when a failure truly means the program cannot continue. Use unwrap() only in tests or throwaway prototypes where panics are acceptable.

Use &T and slices when you need temporary read access to data. Use Cow<'_, T> when you want to borrow by default but need to mutate occasionally. Use Rc<T> or Arc<T> when multiple parts of your program need to share ownership of the same heap allocation.

Use safe standard library types when the compiler rejects your code. Use RefCell<T> for runtime borrow checking in single-threaded contexts. Use Mutex<T> or RwLock<T> when data crosses thread boundaries. Use unsafe only when you are implementing a safe abstraction from scratch, binding to a foreign library, or optimizing a measured bottleneck that safe code cannot touch.

Reach for plain references when lifetimes are simple. The unsafe alternative is rarely worth it.

Where to go next