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:
| Technique | Impact |
|---|---|
Use cargo check during development | Large - skip codegen entirely |
| Split into smaller crates (workspace) | Medium - only recompile changed crates |
| Reduce generics / trait bounds | Medium - less monomorphization |
Use lld or mold as linker | Medium - faster linking |
| Enable incremental compilation | On 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.
| Type | What it is | When to use |
|---|---|---|
String | Owned, heap-allocated, growable | When you need to store or modify a string |
&str | Borrowed slice (a view into a String or literal) | Function parameters, read-only access |
&'static str | String literal baked into the binary | Constants, 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, notString. To get aString, use"hello".to_string()orString::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:
| Trait | Captures by | Can call | Example |
|---|---|---|---|
Fn | Shared reference (&T) | Multiple times | |x| println!("{x}") |
FnMut | Mutable reference (&mut T) | Multiple times | |x| count += x |
FnOnce | Value (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:
moveclosures 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
Pindirectly in application code. It shows up when you’re implementing low-level async primitives or working withselect!. If you seePinin an error message, tryBox::pin(future)ortokio::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:
| Change | What it means |
|---|---|
gen keyword reserved | Can’t use gen as an identifier (future generators) |
| RPIT lifetime capture rules | Return-position impl Trait captures all in-scope lifetimes by default |
Unsafe attributes require unsafe(...) | #[unsafe(no_mangle)] instead of #[no_mangle] |
tail_expr_drop_order | Temporaries in tail expressions drop before local variables |
New use<> syntax | Explicitly 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:
| Error | Likely 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.