The double-lookup trap
You're building a word counter. You grab a HashMap. You loop through a list of words. For each word, you check if it's already in the map. If it is, you increment the count. If it isn't, you insert the word with a count of one.
It works. But you just performed two hash lookups for every single word. One lookup to check existence. A second lookup to modify the value. The hash function runs twice. The collision resolution logic runs twice. The CPU cache line is fetched twice. In a tight loop processing millions of items, that wasted work adds up to real latency.
Rust gives you a tool to fix this: the Entry API. It lets you perform the lookup once, get a handle to the result, and decide what to do. You pay for the hash exactly once.
The concierge analogy
Think of a hotel concierge. You ask for room 404. The concierge checks the registry. That's the lookup.
If the room is occupied, the concierge hands you the key. If the room is empty, the concierge hands you a blank key card and tells you to write your name. In both cases, you get the result of the check and the authority to interact with the room in one interaction. You never ask "is room 404 taken?" and then walk back to the desk to ask "give me room 404" separately.
The Entry API works the same way. You call entry(key). Rust computes the hash and finds the bucket. It returns an Entry object. This object holds the state of the lookup. You then tell the entry what to do. If the bucket is empty, you provide a default value to insert. If it's full, you get a mutable reference to the existing value. The lookup happens once. The decision happens once.
Minimal example
Here is the word counter rewritten with entry.
use std::collections::HashMap;
fn main() {
let mut counts = HashMap::new();
// Split the string into words and iterate.
for word in "hello world wonderful world".split_whitespace() {
// entry() performs the lookup once and returns an Entry handle.
// or_insert(0) resolves the entry:
// - If the key exists, it returns a mutable reference to the value.
// - If the key is missing, it inserts 0 and returns a mutable reference to that new value.
let count = counts.entry(word).or_insert(0);
*count += 1;
}
println!("{counts:?}");
}
The hash for "world" is computed only when entry("world") runs the second time. The first time, the key is missing, so 0 is inserted. The second time, the key exists, so the reference to the existing count is returned. No second hash. No second probe.
Stop asking twice. Ask once and act.
How the Entry state works
The entry method returns an enum-like value called Entry. It has two variants: Vacant and Occupied.
When you call map.entry(key), Rust determines which variant applies. The Entry object holds a mutable borrow on the map internally. This borrow prevents you from accessing the map in other ways while you're deciding what to do with the entry. It guarantees that no one else can mutate the map while you're in the middle of this operation.
Methods like or_insert consume the Entry and return the result. They are "resolvers." You can also inspect the state manually if you need complex logic.
use std::collections::HashMap;
fn main() {
let mut map = HashMap::new();
match map.entry("config") {
// The key exists. We get a mutable reference to the value.
std::collections::hash_map::Entry::Occupied(mut entry) => {
// Update the existing value.
let val = entry.get_mut();
*val = "updated";
}
// The key is missing. We get a VacantEntry to insert.
std::collections::hash_map::Entry::Vacant(entry) => {
// Insert the default.
entry.insert("default");
}
}
}
Most of the time, you don't need the match. The resolver methods like or_insert and or_insert_with handle the branching for you. Use the match only when you need side effects that depend on whether the key was present or not.
The Entry API keeps the mutation localized. Trust the borrow checker here. It ensures the map stays consistent while you're working.
The and_modify pattern
Sometimes you only want to update if the key exists, but you still want to insert a default if it doesn't. You can chain and_modify before or_insert.
use std::collections::HashMap;
fn main() {
let mut scores = HashMap::new();
let players = ["alice", "bob", "alice", "charlie", "bob"];
for player in players {
// and_modify runs the closure only if the key exists.
// or_insert runs only if the key is missing.
// Exactly one branch executes per call.
scores
.entry(player)
.and_modify(|score| *score += 10)
.or_insert(0);
}
println!("{scores:?}");
}
This reads left to right. If the entry is occupied, the closure modifies the value. If the entry is vacant, or_insert provides the initial value. You get a clean, single-line update-or-insert pattern.
This is cleaner than checking get_mut, branching, and then inserting. It keeps the logic inside the map operation.
Convention: lazy defaults matter
Watch how you create the default value. or_insert takes a value by value. That value is evaluated immediately, before the entry checks if it's needed.
// BAD: Vec::new() runs every iteration, even if the key exists.
// The empty Vec is created and dropped repeatedly.
map.entry(key).or_insert(Vec::new());
If the default is cheap, like 0 or false, this doesn't matter. If the default is expensive, you're wasting resources. Use or_insert_with instead. It takes a closure. The closure runs only when the key is missing and insertion is actually required.
// GOOD: The closure runs only when the key is missing.
map.entry(key).or_insert_with(Vec::new);
This is the community convention. Always use or_insert_with for non-trivial defaults. It signals to readers that you considered the cost. It also prevents accidental allocations or computations in hot loops.
Pitfalls and compiler errors
The Entry API holds a mutable borrow on the map while the entry is alive. You cannot borrow the map again until the entry is dropped.
let mut map = HashMap::new();
let entry = map.entry("key"); // map is borrowed mutably here
let val = map.get("key"); // ERROR
The compiler rejects this with cannot borrow map as immutable because it is also borrowed as mutable (E0502). The entry holds the mutable borrow. You can't reach back into the map while the entry exists.
Fix this by using the entry to get what you need. Don't hold the entry and then query the map. If you need to do multiple operations, resolve the entry first.
let mut map = HashMap::new();
let count = map.entry("key").or_insert(0);
*count += 1;
// entry is dropped here. map is free to borrow again.
let val = map.get("key"); // OK
Another common issue is trying to use entry on an immutable map. entry requires &mut self. If your function takes &HashMap, you can't use it.
fn read_map(map: &HashMap<&str, i32>) {
map.entry("key").or_insert(0); // ERROR
}
The compiler gives cannot borrow as mutable, as it is not declared mutable (E0596). You need &mut HashMap to use the Entry API. If you only have an immutable reference, you're stuck with get and insert separately, which brings back the double lookup. Design your APIs to accept &mut when mutation is possible.
Don't fight the borrow checker here. Drop the entry before you touch the map again.
When to use Entry vs alternatives
Use entry when you need to update a value if it exists or insert a default if it doesn't. This covers the common get-or-create pattern without double lookups.
Use entry when you are accumulating data into collections, like pushing to a Vec or summing into a counter. The single lookup saves significant time in tight loops.
Use entry when you want to chain and_modify for concise update-or-insert logic. It keeps the branching localized and readable.
Reach for get followed by insert when your logic branches heavily and you need to perform side effects before deciding whether to insert. If you need to log, validate, or transform data based on the key's presence before touching the map, separate the lookup from the mutation.
Reach for get alone when you only need to read data. entry requires &mut self, so it won't compile on an immutable map. If you're just reading, stick to get.
If you're doing two lookups, you're doing it wrong. Entry is the fix.