HashMap for Lookups, BTreeMap for Order: Choosing With the Query, Not the Habit
Two map types ship in the standard library, and the choice is decided by what your readers do with the data:
use std::collections::{HashMap, BTreeMap};
let mut scores: HashMap<&str, u32> = HashMap::new();
scores.insert("ada", 90);
let mut ranks: BTreeMap<&str, u32> = BTreeMap::new();
ranks.insert("ada", 90);
HashMap hashes keys into buckets: lookup, insert, and remove are O(1) average, degrading to O(n) only under adversarial collisions. The default hasher is randomized per process (RandomState), so two runs of the same program iterate in different orders — that randomization is deliberate, it is the defense against hash-flooding denial-of-service, and it is also why you must never depend on HashMap iteration order or write a test that asserts one. BTreeMap keeps keys in a balanced binary search tree: O(log n) for every operation, but iteration comes out sorted and range queries are first-class:
let mut prices: BTreeMap<String, f64> = BTreeMap::new();
// every operation O(log n)
for (sku, price) in prices.range("A000"..="B999") { // ordered scan of a key window
println!("{sku}: {price}");
}
HashMap has no range at all — a windowed query means iterating everything and filtering, O(n). The decision table:
| Requirement | Choose | Why |
|---|---|---|
| Point lookups by key | HashMap |
average O(1) vs O(log n) |
| Sorted iteration / min / max | BTreeMap |
order is the data structure |
| Range or predecessor queries | BTreeMap |
range, first_key_value, last_key_value |
| Memory footprint | BTreeMap (often) |
no hasher state, nodes are compact |
| Deterministic order in tests | BTreeMap |
sorted output, no RandomState |
Key with expensive Ord but cheap Hash |
HashMap |
hashing runs once per lookup; comparison runs log n times |
Type bounds differ too, and they bite during refactors: a HashMap key needs Eq + Hash, a BTreeMap key needs Ord (which implies Eq). That is why f64 can never be a map key in either — f64 implements neither Hash nor Ord (NaN breaks reflexivity: NaN != NaN), so you wrap it in a newtype and hash the bits, or key by an integer. A newtype around an ID with #[derive(Hash, Eq, PartialEq)] is the standard fix for a domain key.
The Hash + Eq contract deserves its own sentence because violating it is the classic map bug: if two keys are equal, they must produce the same hash. Derive both together (#[derive(PartialEq, Eq, Hash)]) and never hand-write Eq in a way that disagrees with Hash. Break it and entries become unreachable — inserted under one hash, looked up under another, silently None, no error anywhere.
When performance claims matter: HashMap with SipHash (the default) trades speed for DoS resistance; a HashMap<K, V, BuildHasherDefault<...>> or the fxhash/ahash crates speed up integer-heavy workloads when all keys are trusted. BTreeMap nodes allocate per entry and compare keys log n times per operation — for small maps (a few dozen entries) the difference is noise, which is exactly when a BTreeMap's deterministic order is worth taking. In interviews, "when would you pick BTreeMap over HashMap?" is answered with order and ranges, not with speed.
Two API details that round out the choice: BTreeMap exposes range, range_mut, first_key_value, last_key_value, and pop_first/pop_last (ordered extraction — a min-heap workload without a heap), while HashMap offers none of those but adds try_fold-style iteration and cheap retain. Both provide Default, Extend<(K, V)>, and FromIterator, so collect() builds either from a tuple iterator identically — swapping containers rarely changes the pipeline, only the capabilities.
The Entry API: One Lookup Instead of Two
The counting bug every language has:
let mut counts: HashMap<String, u32> = HashMap::new();
for word in text.split_whitespace() {
if let Some(n) = counts.get_mut(word) { // lookup #1
*n += 1;
} else {
counts.insert(word.to_string(), 1); // lookup #2 (hash again, find slot again)
}
}
Two hash computations and up to two probes per word, plus an allocation of the key in the miss branch that the entry API lets you defer. The same loop with entry:
for word in text.split_whitespace() {
*counts.entry(word.to_string()).or_insert(0) += 1;
}
One lookup, one code path. entry takes the key by value and returns an Entry enum that has already located the slot:
| Call | Behavior on vacant | Behavior on occupied |
|---|---|---|
or_insert(v) |
inserts v |
returns existing &mut V |
or_insert_with(f) |
runs closure, inserts result | returns existing &mut V, closure never runs |
or_default() |
inserts V::default() |
returns existing &mut V |
and_modify(f) |
does nothing | applies f(&mut V) |
and_modify(f).or_insert(v) |
inserts v |
applies f to the old value |
or_insert_with matters the same way unwrap_or_else does — the closure defers an allocation or a computation to the branch that needs it:
let start = cache
.entry(key.clone())
.or_insert_with(|| expensive_load(key)); // not called on a hit
For control flow beyond "insert if missing," match on the entry:
match logs.entry(request_id) {
Entry::Occupied(mut e) => { e.get_mut().push(line); }
Entry::Vacant(e) => { e.insert(vec![line]); }
}
That is and_modify + or_insert spelled out — useful when the two arms need real statements rather than expressions.
The other half of the double-lookup story is insert, which returns the previous value: if let Some(old) = map.insert(k, v) { ... } tells you an overwrite happened, so detecting collisions (a key you assumed unique) costs nothing extra. Pair it with contains_key only when you need to test without writing — and when you truly need "get, and if absent, compute and store," entry still wins because it does the locate once.
Two details that make entry feel native rather than exotic. First, the API is identical on BTreeMap — same entry, or_insert*, and_modify methods, only the path differs (std::collections::btree_map::Entry) — so switching containers never rewrites your update logic. Second, entry hands back &mut V directly, so a simple policy stays one expression, and anything with real statements in both branches grows into a match on the entry instead of a nested conditional:
*hits.entry(path.to_string()).or_insert(0) += 1; // one lookup, one expression
// both branches carry statements:
match counters.entry(user) {
Entry::Occupied(mut e) => { *e.get_mut() = (*e.get_mut()).max(1); }
Entry::Vacant(e) => { e.insert(1); }
}
Grouping is the canonical real-world use — one pass over records, no intermediate Vecs:
let mut by_tenant: HashMap<String, Vec<Order>> = HashMap::new();
for o in orders {
by_tenant.entry(o.tenant.clone()).or_default().push(o); // or_default(): Vec::new() on miss
}
or_default() on a HashMap<String, Vec<_>> inserts an empty Vec only when the tenant is new, and push then hits an existing (or fresh) bucket. The clone of o.tenant is the one allocation the entry API cannot avoid — because the key must be owned by the map — and it is exactly the kind of cost you accept once, per grouping, instead of per comparison.
Micro-note worth knowing: entry consumes the key, so counts.entry(word.to_string()) allocates the String even when the key is already present — for hot paths over borrowed data, pre-check with get_key_value (it returns (&K, &V) so you can clone the existing key instead of allocating a new one) or keep keys as &str borrowed from an arena. The map-side cost is one probe either way; the allocation is what you can avoid.
Borrowed Lookups, Owned Keys, and Removal Without Surprises
A HashMap<String, u32> can be queried with a plain &str without building a String — and understanding why prevents the "why doesn't this compile" week:
let mut ages: HashMap<String, u32> = HashMap::new();
ages.insert("ada".to_string(), 36);
let n = ages.get("ada"); // works: get<Q: ?Sized>(&self, k: &Q)
// where String: Borrow<Q>, Q: Hash + Eq
The Borrow bound is the contract: String: Borrow<str> (and Borrow<String>), so get, contains_key, remove, and entry accept &str for a String-keyed map — hashing the borrowed form must agree with hashing the owned form, which is exactly the same "consistent hash" requirement from the previous chapter. Consequences: lookups over &str never allocate, and a HashMap<String, _> you only read can be built from borrowed slices (HashMap<&str, _> where the source strings outlive it — the lifetime rules from the reference topics apply unchanged).
Removal has three shapes, each with a different return:
let v: Option<u32> = ages.remove("ada"); // just the value
let kv: Option<(String, u32)> = ages.remove_entry("ada"); // value AND the owned key back
ages.retain(|_k, v| *v >= 18); // bulk filter, one pass
let leftover: HashMap<String, u32> = std::mem::take(&mut ages); // move the whole map out
remove_entry is the one people miss — when the key is expensive to reconstruct (a parsed identifier, a joined path), getting it back out with the value avoids re-deriving it. retain mirrors Vec::retain: one pass, no intermediate allocation. And std::mem::take (or mem::replace) moves the map out while leaving an empty one in place, which is how you hand a buffer of state to a worker without cloning.
Iteration deserves a warning regardless of which map you chose:
for (k, v) in &map { } // borrows: map still usable
for (k, v) in map { } // consumes: pairs are moved out (keys and values)
map.iter_mut() // &mut V, mutate values in place (keys are immutable by design)
Mutating keys is impossible through iteration because that would break the map's own invariants — the key's position depends on its hash or its sort order. To change a key, remove the old entry and insert under the new one; this is a design statement, not a limitation: &mut K would let an entry become unfindable. Inserting while iterating has the same borrow-conflict shape you met with Vec — collect your additions first, or use extend after the loop.
Two value-update shapes, and when each is right: get_mut when the key is known to exist (a loop over a pre-populated config) — it is a plain lookup with a mutable borrow; entry when existence is genuinely unknown — it is the lookup-plus-decision in one call. Mixing them up costs either a redundant contains_key (with get_mut) or a redundant probe (with entry), and only one of those is visible in a profile.
Borrowed keys are the other everyday design choice, and lifetimes make the trade explicit:
let words: Vec<&str> = text.split_whitespace().collect();
let mut counts: HashMap<&str, usize> = HashMap::new();
for w in words { *counts.entry(w).or_insert(0) += 1; }
// `counts` borrows from `text` — it cannot outlive the buffer it indexes into
Keying by &str means zero allocations for keys, and the compiler enforces the data's lifetime: the moment text would be dropped, counts becomes a borrow error. That is usually what you want for short-lived aggregation; when the map must outlive its source — cached parsed names, a struct's field — clone the keys into Strings at insert time and pay one allocation per distinct key, once.
Finally, capacity: HashMap::with_capacity(n) reserves buckets up front (load factor ~0.75 means it reserves a bit more than n), and reserve(additional) avoids rehashing inside a bulk-load loop. A HashMap that grows during a 100k-element import rehashes log-wise several times; one with_capacity call turns that into a single allocation. This is the same reallocation discipline as Vec and Dynamic Arrays, one level up.
Quiz
1. You need to iterate invoice IDs in ascending order and run `range(start..=end)` queries. Which map?
2. What does `map.insert(k, v)` return, and why does it matter?
3. A `HashMap<String, i32>` is queried with `map.get("port")` where the argument is `&'static str`. Why does that compile?
4. Your custom `UserKey` is inserted and then `get` returns None even though you just inserted it. What is the most likely cause?
Flashcards
Question
HashMap vs BTreeMap — complexity, bounds, and iteration order.
Click to reveal answer
Answer
HashMap: average O(1) get/insert/remove, keys need `Eq + Hash`, order is randomized per process (`RandomState`) — never assert on it. BTreeMap: O(log n) everywhere, keys need `Ord`, iteration is sorted and `range`/`first_key_value` are available.
Question
What is the `entry` API for, and what does `or_insert_with` guarantee?
Click to reveal answer
Answer
It performs a single lookup and returns an `Entry::{Occupied, Vacant}` describing the slot, so check-and-insert is one code path instead of `contains_key` + `insert`. `or_insert_with(f)` runs `f` only when the key is absent — the closure never executes on a hit, which keeps expensive defaults off the hot path.
Question
State the Hash/Eq contract for map keys and what violating it looks like.
Click to reveal answer
Answer
If `a == b` then `hash(a) == hash(b)` — always derive `PartialEq, Eq, Hash` together and keep the same fields in both. Violation symptoms: entries you just inserted come back `None`, no error, because insert and get probe different buckets.
Question
What does `Borrow` buy you when a map is keyed by `String`?
Click to reveal answer
Answer
`get`/`remove`/`contains_key`/`entry` accept `&str` (any `Q` where `String: Borrow<Q>, Q: Hash + Eq`), so lookups borrow instead of allocating a temporary String — and hashing the borrowed bytes must equal hashing the owned key for the entry to be found.
Question
Three removal tools and what each returns.
Click to reveal answer
Answer
`remove(k) -> Option<V>` (value only); `remove_entry(k) -> Option<(K, V)>` (value plus the owned key back); `retain(|k, v| keep)` (bulk filter in one pass). `mem::take(&mut map)` moves the entire map out, leaving an empty one — state handoff without cloning.
Revision Notes
Key Takeaways
- 1. `HashMap` gives average O(1) with randomized iteration order (the hash-flooding defense); `BTreeMap` gives O(log n) with sorted iteration and native `range` queries — order, not speed, decides between them
- 2. The `Hash`+`Eq` contract is the map's correctness invariant: derive them together or entries silently become unreachable
- 3. `entry` collapses check-and-insert into one lookup, and `or_insert_with` defers the expensive default exactly like `unwrap_or_else`
- 4. `insert` returns the displaced `Option<V>`, so overwrite detection is free; `remove` vs `remove_entry` decides whether you get the key back
- 5. `Borrow` lets `String`-keyed maps answer `&str` lookups without allocating — provided hashing agrees between the owned and borrowed forms
Interview Tips
- • "\"HashMap or BTreeMap?\" — answer from the query: point lookups → HashMap; sorted iteration/range/need deterministic order → BTreeMap; add the bounds (`Eq + Hash` vs `Ord`) and the randomized-order gotcha"
- • "\"What does entry do better?\" — one hash/probe instead of two, plus a single code path; give the word-count example and note `or_insert_with` never runs the closure on a hit"
- • "\"Why does my newly inserted key return None?\" — Hash/Eq mismatch; ask which fields are in each impl, this demonstrates you have debugged the bug rather than read about it"
- • "\"Can I change a key in place?\" — no: `&mut K` would break hash/sort placement; remove the entry and re-insert, and explain why the API gives `&mut V` but never `&mut K`"
Cheat Sheet
HashMap and BTreeMap
Choose by query:
| Need | Pick |
|---|---|
| point lookups, avg O(1) | HashMap (keys: Eq + Hash) |
sorted iteration, range(a..b), min/max |
BTreeMap (keys: Ord) |
| deterministic test output | BTreeMap |
| small map, cheap memory | either — measure |
Insert / check / count:
*map.entry(k).or_insert(0) += 1; // one lookup
map.entry(k).or_insert_with(|| load()); // default only on miss
map.entry(k).and_modify(|v| *v += 1).or_insert(1);
match map.entry(k) { Entry::Occupied(mut e) => ..., Entry::Vacant(e) => ... }
let old: Option<V> = map.insert(k, v); // Some(old) = overwrite
Read / remove:
map.get("borrowed-str") // works for String keys: K: Borrow<Q>
map.get_mut(&k) // mutate the value in place
map.remove(&k) -> Option<V> map.remove_entry(&k) -> Option<(K, V)>
map.retain(|_k, v| keep(v)); std::mem::take(&mut map)
Invariants: equal keys must hash equally (derive PartialEq, Eq, Hash together); keys are immutable while stored (change = remove + insert); no mutation while iterating (borrow conflict).