Rust Coding Interview Gotchas: The Footguns That Compile Fine

- usize underflow silently wraps to
usize::MAXin release mode; cast indices toi64early and only convert back at the moment of array access. - f64 has no
Ord:vec.sort()won't compile on floats; usesort_by(|a, b| a.total_cmp(b))instead. BinaryHeapis a max-heap by default: wrap values withReverse<T>on push and destructure on pop to get min-heap behavior for Dijkstra's and similar patterns.- String indexing is banned at compile time: use
s.as_bytes()[i]for ASCII input or collect intoVec<char>for repeated random access. ascasts silently truncate:-1i64 as usizeequalsusize::MAXwith no warning; work entirely ini64and only cast tousizeat the final array access.i32arithmetic overflows silently in release mode: cast toi64before summing, multiplying, or comparing across elements.- The entry API (
.entry(k).or_insert(0)) is the correct pattern for frequency maps; separateget+insertcauses lifetime conflicts.
Rust feels safe. The compiler catches memory bugs that C++ lets slip through at 3am. But "memory safe" is not the same as "correct," and the most dangerous Rust interview gotchas are perfectly valid, compiler-approved code that explodes at runtime when you least expect it.
You will write one of these bugs. You will stare at it for two minutes while your interviewer watches. This post is the inoculation.
usize Underflow: 18.4 Quintillion Is Not a Valid Array Index
This is the single most common Rust interview bug. usize is unsigned. Subtract past zero and you don't get a negative number. In debug mode, you get a panic. In release mode (which is what LeetCode's judge uses), you get usize::MAX: 18,446,744,073,709,551,615. Your code will then attempt to use that as an array index.
let i: usize = 0; let j = i - 1; // panic in debug, wraps to usize::MAX in release
Cast your indices to i64 at the top of the function and only convert back to usize for actual array access. This is especially painful in binary search (when hi is 0 and you compute hi - 1), sliding window problems, and any backward-walking loop.
// Risky let mut hi: usize = nums.len() - 1; // panics if nums is empty // Safe let mut hi: i64 = nums.len() as i64 - 1; // ... logic ... let val = nums[hi as usize]; // only cast when indexing
The empty-input case is particularly nasty because nums.len() - 1 on an empty Vec panics immediately, before your loop even starts. You will find this out during your interviewer's first test case.
NaN Broke Floats. f64 Has No Ord. sort() Won't Compile.
Floats have NaN. NaN < NaN is false. NaN == NaN is also false. NaN is the chaotic neutral of numeric types, and it breaks the reflexivity and total ordering that Ord requires. So f64 only implements PartialOrd, not Ord.
The consequence: vec.sort() is a compile error on Vec<f64>. You also can't use f64 as a BTreeMap or BTreeSet key, and the standard std::cmp::max() and min() won't accept floats.
Use sort_by with total_cmp, which establishes a total ordering by placing NaN consistently at the end.
let mut weights: Vec<f64> = vec![3.1, 1.4, 2.7, 0.5]; weights.sort_by(|a, b| a.total_cmp(b)); // Or descending weights.sort_by(|a, b| b.total_cmp(a));
total_cmp was stabilized in Rust 1.62. If you see partial_cmp().unwrap() in older code, total_cmp is the cleaner replacement. The unwrap() version will also panic on NaN, which is a fun time.
BinaryHeap Is a Max-Heap. Every Algorithm Wants a Min-Heap.
Rust's std::collections::BinaryHeap pops the largest element first. Dijkstra's, k-closest, merge-k-sorted-lists, and most other heap-based interview patterns want the smallest element first. Every time.
The standard fix is std::cmp::Reverse<T>, a wrapper that flips the comparison. It is one of those things that is slightly annoying to type but saves you from reimplementing a heap in an interview.
use std::collections::BinaryHeap; use std::cmp::Reverse; let mut min_heap: BinaryHeap<Reverse<i32>> = BinaryHeap::new(); min_heap.push(Reverse(5)); min_heap.push(Reverse(1)); min_heap.push(Reverse(3)); let Reverse(smallest) = min_heap.pop().unwrap(); // smallest == 1
Wrap with Reverse on push, destructure on pop. For tuples (distance, node) in Dijkstra's, wrap the whole tuple: heap.push(Reverse((dist, node))). The comparison falls through to the first element automatically.
s[i] Doesn't Compile. Also, .chars().nth(i) Is O(n).
String stores UTF-8 bytes. Indexing by position would land you in the middle of a multi-byte character, so Rust prohibits it at compile time. s[i] is a compile error, full stop. Rust is not apologizing for this.
Your options, with their actual costs:
| Approach | Cost | Use when |
|---|---|---|
s.as_bytes()[i] | O(1) | Input is guaranteed ASCII |
s.chars().nth(i) | O(n) | Rare, one-off access |
let chars: Vec<char> = s.chars().collect() | O(n) once | Repeated random access |
For LeetCode string problems, the ASCII assumption almost always holds. .as_bytes() gives you u8 values, which you can compare directly (b'a', b'z', etc.) and use as array indices for frequency counts.
One more trap: .len() returns the byte count, not the character count. For ASCII they're equal. For a string with multi-byte characters, they diverge silently and your off-by-one error will be very hard to explain.
let s = String::from("hello"); let bytes = s.as_bytes(); let freq = bytes[0] - b'a'; // 'h' = 7, clean and O(1)
The Borrow Checker Hates Graph Traversal
You have a graph as HashMap<usize, Vec<usize>>. You iterate over neighbors of node u. You also need to mark nodes visited in a HashSet. The borrow checker sees an immutable borrow of the map for reading neighbors and a mutable borrow for the visited set, and it objects. Loudly.
The more common version: you hold &adj[&u] across a recursive DFS call that also takes &mut adj. Even if the two references don't actually overlap in memory, the compiler can't prove that at compile time. Your problem, not the compiler's.
The idiomatic fix is to clone the neighbor list before iterating.
fn dfs(node: usize, adj: &HashMap<usize, Vec<usize>>, visited: &mut HashSet<usize>) { if !visited.insert(node) { return; } let neighbors = adj.get(&node).cloned().unwrap_or_default(); for next in neighbors { dfs(next, adj, visited); } }
The clone costs an allocation per call. For interview code, that's fine. For large graphs, Vec<Vec<usize>> (index-based adjacency list) sidesteps most lifetime headaches entirely and is also faster.
for Loops Move the Collection. Python Has Ruined You.
for x in collection moves collection. After the loop, it no longer exists. This surprises engineers coming from Python or Java, where iteration is always borrowing and the original collection is just sitting there waiting for you.
let nums = vec![1, 2, 3]; for n in nums { println!("{}", n); } // compile error: use of moved value: `nums` println!("{:?}", nums);
Use for x in &collection or for x in collection.iter() to borrow. Both yield &T references. Use for x in collection.iter_mut() for mutable references.
for x in &vec calls .into_iter() on the reference, which is why borrowing works. The rule most engineers internalize quickly: if you need the vec after the loop, use &. You will forget this rule once, get a compile error, and never forget it again.
as Casts Silently Truncate. -1i64 as usize Is usize::MAX.
The as keyword performs a best-effort conversion without checking range. It's fast, it compiles, and it will give you wrong answers without any error.
let x: i64 = -1; let y = x as usize; // y == 18446744073709551615 (usize::MAX) let z: i32 = 300; let w = z as u8; // w == 44 (300 mod 256)
The as usize from a negative i64 is the dangerous one. It comes up when you compute an index offset that could be negative (difference of positions, reversed index) and cast it to use for array access. No panic, no compiler warning. Your program just reads from a completely different memory location and produces nonsense.
For safety, use usize::try_from(x) which returns a Result, or check the range explicitly before casting.
// Safe version let idx = i64::try_from(some_value) .ok() .and_then(|v| if v >= 0 { Some(v as usize) } else { None });
In practice, the right habit is to work entirely in i64 for index arithmetic and only cast to usize at the last moment when you're confident the value is non-negative.
i32 Arithmetic Overflows. In Release Mode. Silently.
LeetCode problems hand you Vec<i32>. Individual values fit in i32, but summing them, multiplying them, or computing differences between them frequently doesn't. The problem constraints don't always warn you.
In debug mode, this panics with a clear message. In release mode (LeetCode's judge), it silently wraps to a negative number and your solution returns a completely wrong answer with no indication of why.
// Overflows silently when elements are large let sum: i32 = nums.iter().sum(); // Safe let sum: i64 = nums.iter().map(|&x| x as i64).sum();
Cast to i64 before any arithmetic that touches more than a single element. The classic binary search midpoint is a subtler version of this: (lo + hi) / 2 overflows if both are near i32::MAX. The fix is lo + (hi - lo) / 2, which you have now seen enough times to write in your sleep.
collect() Needs a Type Annotation. The Compiler Will Not Guess.
The compiler infers iterator transformations well but often can't figure out what collect() is building. You'll see error[E0282]: type annotations needed with no obvious cause and spend 30 seconds staring at something that just needs one word added.
// Compile error let doubled = nums.iter().map(|&x| x * 2).collect(); // Fine let doubled: Vec<i32> = nums.iter().map(|&x| x * 2).collect(); // Also fine (turbofish, beloved by those who name things) let doubled = nums.iter().map(|&x| x * 2).collect::<Vec<i32>>();
Add the type on the let binding or use turbofish. This also applies when collecting into HashMap, HashSet, or String. The annotation is always required and turbofish (::<Vec<i32>>) is always slightly funnier to type than it should be.
Double Lookup in HashMap Loses the Borrow Fight. Use entry.
Two separate lookups with get followed by insert cause a borrow lifetime battle. The entry API does it in one shot and is the idiomatic Rust pattern for building frequency maps and adjacency lists.
// Frequency map *map.entry(key).or_insert(0) += 1; // Adjacency list adj.entry(u).or_default().push(v);
The * before map.entry(...) is not decorative. or_insert returns &mut V. Dereference it to modify the value in place. Leaving out the * causes a type error that will make you question your life choices for a moment.
Use .and_modify().or_insert() when you want different behavior for existing vs new keys:
map.entry(key) .and_modify(|v| *v += 1) .or_insert(1);
Quick Reference
| Trap | Fix |
|---|---|
i - 1 panics when i: usize is 0 | Cast to i64, subtract, bound-check |
vec.sort() on Vec<f64> won't compile | vec.sort_by(|a, b| a.total_cmp(b)) |
BinaryHeap pops largest | Wrap with Reverse<T> on push and pop |
s[i] compile error | s.as_bytes()[i] (ASCII) or Vec<char> |
| Graph DFS borrow conflict | let neighbors = adj.get(&u).cloned()... |
for x in vec consumes vec | for x in &vec |
-1i64 as usize = usize::MAX | Work in i64, cast only at access time |
i32 sum overflows | nums.iter().map(|&x| x as i64).sum() |
collect() type error | Annotate the binding or use turbofish |
| Double lookup in HashMap | .entry(k).or_insert(0) |
Knowing the gotchas is one thing. Getting fluent enough to catch them mid-problem, under time pressure, while narrating your thinking out loud, is a different skill entirely. SpaceComplexity runs voice-based mock interviews with rubric feedback on exactly the dimensions that get you hired. If Rust is your language, you will see these traps come up. Better to face them in practice than in the actual round.
Also see Rust for Coding Interviews for the full collections reference, and Binary Search: One Invariant for the midpoint overflow problem in detail. If you're choosing between Rust and another language, Best Language for Coding Interviews has the tradeoff breakdown.