HashMaps are one of the most common data structures in Rust, and also one of the first places where ownership, borrowing, and references really start to matter.

This post walks through 10 progressively more complex exercises that cover almost every practical HashMap pattern you'll use in real Rust code. Each exercise focuses on one idea, uses idiomatic Rust, highlights ownership and borrowing decisions, and builds on the previous ones.

If you can write and understand all of these, you're no longer "learning HashMap". You're using it fluently.


Prerequisites

You should be comfortable with basic Rust syntax, Vec, &str, String, ownership vs borrowing, and iterators and for loops.

We’ll use only the standard library:

use std::collections::HashMap;

1. Counting words (the canonical HashMap problem)

Goal: Learn the entry() + or_insert() pattern.

pub fn word_count(words: &[&str]) -> HashMap<String, usize> {
    let mut map = HashMap::new();

    for word in words {
        *map.entry(word.to_string()).or_insert(0) += 1;
    }

    map
}

This is usually the first real problem people solve with a hash map, and in Rust it introduces the most important part of the API: entry(). Instead of checking whether a key exists and then updating or inserting manually, entry() gives you a handle that represents either case. Calling or_insert(0) ensures a value is present and returns a mutable reference to it.

That return type is the key detail: or_insert yields a &mut usize, not a value. Rust requires you to explicitly dereference it before mutating, which is why the * is necessary. This may feel a bit verbose at first, but it’s Rust making mutation explicit and safe.

You’ll see this exact pattern -- *map.entry(key).or_insert(0) += 1 -- over and over again in real Rust code, from frequency counters to aggregations. Once it clicks, working with HashMap stops feeling awkward and starts feeling precise and powerful.


2. Character frequency

Goal: Same idea, different key type.

pub fn char_frequency(s: &str) -> HashMap<char, usize> {
    let mut map = HashMap::new();

    for c in s.chars() {
        *map.entry(c).or_insert(0) += 1;
    }

    map
}

This example reinforces that the entry() pattern is completely independent of the key type. Instead of owned Strings, we’re now using char keys produced by iterating over the string. Because char is a small, Copy type, we can insert it directly into the map without any allocation or cloning.

What’s important here is that nothing about the counting logic changes. Once you understand how entry() and or_insert() work, switching from words to characters—or to any other key type—is mostly mechanical. This is a recurring theme in Rust: the hard part is learning the pattern; applying it to different data is easy.

The repetition is intentional. By solving the same problem with a slightly different shape, you start to see HashMap as a flexible tool rather than a special-case container.


3. Safe lookup with a default value

Goal: Read from a HashMap without panicking.

pub fn get_score(scores: &HashMap<String, i32>, name: &str) -> i32 {
    *scores.get(name).unwrap_or(&0)
}

This example shifts focus from insertion to safe reading. When you call get, Rust returns an Option<&i32>, not the value itself. That’s deliberate: the key might not exist, and Rust forces you to acknowledge that possibility. Instead of unwrapping and risking a panic, we provide a fallback with unwrap_or(&0).

Notice that the fallback is a reference to 0, not just 0. That’s because get returns a reference, so the types must match. After that, we dereference the result with * to obtain the actual i32. Since i32 implements Copy, dereferencing simply copies the value out—no ownership complications, no moves.

The pattern here is subtle but important: you borrow from the map, supply a safe default, and only then copy the value if needed. It’s a compact example of how Rust combines Option, references, and the type system to eliminate whole classes of runtime errors while keeping the code concise and expressive.


4. Merging two HashMaps

Goal: Combine values with the same key.

pub fn merge_counts( a: HashMap<String, usize>, b: HashMap<String, usize>,) -> HashMap<String, usize> {
    let mut map = a;

    for (key, value) in b {
        *map.entry(key).or_insert(0) += value;
    }

    map
}

This function builds directly on the counting pattern, but now applied across two maps. By taking ownership of both a and b, it avoids any need for cloning: a becomes the base map, and b is consumed as we iterate over it. Each key–value pair from b is moved into the result, and if the key already exists, its value is incremented rather than replaced.

The important detail is that entry works just as well when merging as it does when counting from scratch. Whether a key is new or already present, the logic stays the same, which makes this pattern easy to reuse and reason about. The result is code that is both efficient—because it moves data instead of copying it. And expressive, because the intent (“add these counts together”) is immediately clear.


5. Finding the most frequent entry

Goal: Iterate over entries and return a derived value.

pub fn most_frequent(map: &HashMap<String, usize>) -> Option<String> {
    map.iter()
        .max_by_key(|(_, count)| *count)
        .map(|(key, _)| key.clone())
}

This example moves from mutation to analysis. Instead of inserting or merging, we iterate over the existing entries and compute a derived result. Calling iter() yields (&K, &V), meaning we’re borrowing both the keys and the values. Nothing is moved out of the map, and the function remains read-only.

max_by_key is where the intent becomes explicit. Rather than manually tracking the “largest so far,” we tell Rust exactly what we want: select the entry with the maximum count. The closure receives references, so we dereference count to compare its numeric value.

Finally, we convert from Option<(&String, &usize)> to Option<String> using map. The only allocation happens at the boundary, where we clone the key for the return value. Everything else stays borrowed. This is a common Rust pattern: iterate by reference for efficiency, then clone only when you must hand ownership to the caller.

The result is compact, expressive, and avoids unnecessary work. Very much in line with idiomatic Rust design.


6. Grouping values (HashMap of Vecs)

Goal: Store multiple values per key.

pub fn group_by_first_letter(words: &[&str]) -> HashMap<char, Vec<String>> {
    let mut map = HashMap::new();

    for word in words {
        if let Some(first_char) = word.chars().next() {
            map.entry(first_char)
                .or_insert_with(Vec::new)
                .push(word.to_string());
        }
    }

    map
}

This exercise introduces a very common variation of the entry pattern: storing collections as values. Instead of counting, each key maps to a Vec that accumulates multiple items. Conceptually, this is how you model "group by" operations in Rust.

The key detail is the use of or_insert_with(Vec::new). Unlike or_insert(Vec::new()), which would eagerly allocate a vector even when the key already exists, or_insert_with only constructs the Vec when it's actually needed. That keeps the code efficient while remaining clear and expressive.

Once the entry exists, push works exactly as you'd expect: you get mutable access to the vector and append a new value. This pattern shows up everywhere—grouping records, building indices, or collecting results by category—and mastering it is a big step toward writing fluent, idiomatic Rust.


7. Inverting a HashMap

Goal: Swap keys and values.

pub fn invert_map(map: HashMap<String, i32>) -> HashMap<i32, String> {
    let mut inverted = HashMap::new();

    for (key, value) in map {
        inverted.insert(value, key);
    }

    inverted
}

This example is all about ownership and intent. By taking the input HashMap by value, the function is free to consume it, moving each key–value pair out without cloning. That makes the transformation both simple and efficient: we iterate over the original map, swap the positions of key and value, and insert them into a new one.

The important subtlety is semantic rather than technical. A HashMap can only have one value per key, so if the original map contains duplicate values, later inserts will overwrite earlier ones. Rust doesn't prevent this because it's not a correctness issue. It's a modeling choice. In some cases this behavior is exactly what you want; in others, you might prefer a HashMap<i32, Vec<String>> to preserve all associations.

This exercise highlights a recurring theme in Rust: the type you choose encodes the guarantees you get. Here, the choice of HashMap<i32, String> explicitly says “one key wins,” and the code cleanly enforces that decision.


8. Filtering entries

Goal: Build a new HashMap from a predicate.

pub fn filter_above(map: &HashMap<String, i32>, threshold: i32) -> HashMap<String, i32> {
    let mut filtered = HashMap::new();

    for (key, value) in map {
        if *value > threshold {
            filtered.insert(key.clone(), *value);
        }
    }

    filtered
}

This exercise focuses on selective ownership. The input map is borrowed, which means we’re not allowed to move anything out of it. Instead, we iterate over references and decide which entries are worth keeping based on a simple predicate.

When a value passes the threshold check, we explicitly clone the key and copy the value into the new map. That makes the ownership boundary very clear: only the entries that survive the filter are duplicated, and everything else remains borrowed. Since i32 implements Copy, dereferencing value is cheap and natural.

While this could be written using iterator chains like filter and collect, the explicit loop keeps the logic readable and approachable. In Rust, clarity often beats cleverness. Especially when ownership and borrowing are part of the lesson.


9. Counting with ownership

Goal: Consume a collection.

pub fn count_numbers(nums: Vec<i32>) -> HashMap<i32, usize> {
    let mut count = HashMap::new();

    for n in nums {
        *count.entry(n).or_insert(0) += 1;
    }

    count
}

This example looks almost identical to earlier counting patterns, but the key difference is in the function signature. By taking Vec<i32> by value, the function consumes the collection. That small choice removes an entire layer of complexity: there’s no borrowing, no references to juggle, and no need to clone anything.

Each number is moved directly out of the vector and into the HashMap as a key. Since i32 implements Copy, even that movement is trivial. The entry pattern works exactly as before, but now the ownership model is as simple as it can possibly be.

This illustrates a powerful Rust principle: if your function logically owns the data, accept ownership in the type signature. Doing so often eliminates lifetime concerns and reduces mental overhead. When you can take ownership cleanly, the code tends to become both simpler and more robust.


10. Nested HashMaps (real-world aggregation)

Goal: HashMap of HashMaps.

pub fn tally_scores(entries: Vec<(&str, &str, i32)>) -> HashMap<String, HashMap<String, i32>> {
    let mut tally = HashMap::new();

    for (player, game, score) in entries {
        *tally
            .entry(player.to_string())
            .or_insert_with(HashMap::new)
            .entry(game.to_string())
            .or_insert(0) += score;
    }

    tally
}

This final exercise combines everything introduced so far into a pattern that closely resembles real production code. We're aggregating data across two dimensions: players and games, using a nested HashMap. Each outer key maps to another HashMap, which in turn accumulates numeric values.

The logic flows from the outside in. First, we ensure that a map exists for a given player, creating one only when necessary with or_insert_with(HashMap::new). From there, we immediately operate on the inner map, applying the familiar counting pattern to the game and score. Although the chaining looks dense at first glance, each step follows the same rules you've already learned.

What's especially important here is how cleanly ownership is handled. The function consumes the input entries, allocates owned Strings at the boundary, and mutates only the structures it owns. There are no lifetimes to track and no intermediate clones beyond what’s required for storage.

This nested entry pattern is common in analytics, logging, metrics, and any kind of aggregation pipeline.

Once you're comfortable reading and writing code like this, HashMaps stop feeling like a special case and start feeling like a natural extension of Rust's type system and ownership model!