Gotchas

Practical pitfalls that trip up Rust developers, from beginners to experienced users.

Compile Times

Rust’s compile times are notoriously slow for large projects. The compiler does a lot of work (monomorphization, borrow checking, optimization).

# Fast feedback: type-check without generating a binary
cargo check

# Auto-rebuild on save
cargo install cargo-watch
cargo watch -x check

# See what's slow
cargo build --timings   # generates an HTML report

Strategies to improve compile times:

TechniqueImpact
Use cargo check during developmentLarge - skip codegen entirely
Split into smaller crates (workspace)Medium - only recompile changed crates
Reduce generics / trait boundsMedium - less monomorphization
Use lld or mold as linkerMedium - faster linking
Enable incremental compilationOn by default for debug builds

Configure a faster linker in .cargo/config.toml:

# macOS
[target.aarch64-apple-darwin]
rustflags = ["-C", "link-arg=-fuse-ld=/opt/homebrew/bin/mold"]

# Linux
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=mold"]

String vs &str

This is the most common source of confusion for new Rust developers.

TypeWhat it isWhen to use
StringOwned, heap-allocated, growableWhen you need to store or modify a string
&strBorrowed slice (a view into a String or literal)Function parameters, read-only access
&'static strString literal baked into the binaryConstants, hardcoded values
// Accept &str in functions - works with both String and &str
fn greet(name: &str) {
    println!("Hello, {name}!");
}

let owned = String::from("Alice");
greet(&owned);    // &String auto-coerces to &str
greet("Bob");     // &str literal works directly

Gotcha: "hello" is &str, not String. To get a String, use "hello".to_string() or String::from("hello"). They’re equivalent; to_string() is more concise.

Converting between them:

let s: String = "hello".to_string();    // &str -> String
let s: String = String::from("hello");  // &str -> String
let r: &str = &s;                       // String -> &str (auto-deref)
let r: &str = s.as_str();              // String -> &str (explicit)

Closure Capture Rules

Closures capture variables from their environment. The capture mode depends on how the variable is used:

TraitCaptures byCan callExample
FnShared reference (&T)Multiple times|x| println!("{x}")
FnMutMutable reference (&mut T)Multiple times|x| count += x
FnOnceValue (moves ownership)Once|| drop(data)
let name = String::from("Alice");

// Fn: borrows name by reference
let greet = || println!("Hi, {name}");
greet();
greet();       // can call multiple times
println!("{name}"); // name still valid

// FnOnce: moves name into the closure
let consume = move || println!("Hi, {name}");
consume();
// println!("{name}"); // Error: name was moved

Gotcha: move closures take ownership of all captured variables, even if the closure body only reads them. If you need to share a value between a closure and the calling code, clone it first.

let data = vec![1, 2, 3];
let data_clone = data.clone();

// Spawned threads require 'static, so move is needed
std::thread::spawn(move || {
    println!("{data_clone:?}");
});

// Original data still available
println!("{data:?}");

Pin in Async Code

Pin prevents a value from being moved in memory. This matters for async because futures can be self-referential (they hold references to their own local variables across await points).

use std::pin::Pin;
use std::future::Future;

// You'll see Pin in:

// 1. Manually implementing Future
impl Future for MyFuture {
    type Output = String;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        // ...
    }
}

// 2. Boxing futures for dynamic dispatch
fn get_data() -> Pin<Box<dyn Future<Output = String>>> {
    Box::pin(async { "hello".to_string() })
}

// 3. Pinning for tokio::select!
let future = some_async_fn();
tokio::pin!(future);

tokio::select! {
    result = &mut future => println!("{result}"),
    _ = tokio::time::sleep(Duration::from_secs(5)) => println!("timeout"),
}

Tip: You rarely need to deal with Pin directly in application code. It shows up when you’re implementing low-level async primitives or working with select!. If you see Pin in an error message, try Box::pin(future) or tokio::pin!(future).

Turbofish Syntax

When the compiler can’t infer a generic type, you specify it with ::<Type>.

let x = "42".parse::<i32>().unwrap();  // turbofish: ::<i32>

let nums: Vec<i32> = vec![1, 2, 3];    // alternative: annotate the binding

// Common with collect
let names: Vec<String> = data.iter().map(|x| x.name.clone()).collect();
// or
let names = data.iter().map(|x| x.name.clone()).collect::<Vec<String>>();

Rust 2024 Edition

The 2024 edition (set with edition = "2024" in Cargo.toml) brings several changes:

ChangeWhat it means
gen keyword reservedCan’t use gen as an identifier (future generators)
RPIT lifetime capture rulesReturn-position impl Trait captures all in-scope lifetimes by default
Unsafe attributes require unsafe(...)#[unsafe(no_mangle)] instead of #[no_mangle]
tail_expr_drop_orderTemporaries in tail expressions drop before local variables
New use<> syntaxExplicitly declare which lifetimes an opaque type captures

To migrate an existing project:

cargo fix --edition
# Then update Cargo.toml: edition = "2024"

Tip: Editions are not breaking changes. Crates on different editions interoperate freely. The edition only affects syntax and defaults within your crate.

Common Compiler Error Fixes

Quick reference for frequent error messages:

ErrorLikely fix
”value moved here”Clone it, use a reference, or restructure ownership
”cannot borrow as mutable”Check for existing shared borrows, split the borrow
”lifetime may not live long enough”Return owned data, or add lifetime annotations
”trait bound not satisfied”Add #[derive(...)] or implement the trait manually
”cannot find value in scope”Check imports (use), check module visibility (pub)
“mismatched types: expected X, found Y”Use .into(), From::from(), or explicit conversion
”unused variable”Prefix with _ or use #[allow(dead_code)]

Tip: Read the full error message - Rust’s compiler errors are famously helpful. They often include a suggestion with the exact code change needed.

Next: Setup | Ownership