Skip to editor content
learningrust.orglesson 12 of 26

Collections in Rust

Collections are data structures that can hold multiple values. Unlike arrays and tuples, which are stored on the stack with a fixed size, collections store their data on the heap and can grow or shrink at runtime. Rust's standard library provides several powerful collection types. In this lesson, we will focus on the three most commonly used: Vec, HashMap, and HashSet.

Vectors: Vec<T>

A Vec<T> is a growable array. It stores elements of a single type in a contiguous block of memory and is the most frequently used collection in Rust:

fn main() {
    // Create a vector with the vec! macro
    let mut scores = vec![85, 92, 78, 95, 88];

    // Add elements
    scores.push(91);
    scores.push(76);

    // Access elements by index (panics if out of bounds)
    println!("First score: {}", scores[0]);

    // Safe access with .get() returns Option<&T>
    match scores.get(100) {
        Some(score) => println!("Score at 100: {}", score),
        None => println!("No score at index 100"),
    }

    // Length and capacity
    println!("Total scores: {}", scores.len());
    println!("Capacity: {}", scores.capacity());

    // Remove the last element
    if let Some(last) = scores.pop() {
        println!("Removed last score: {}", last);
    }

    println!("Scores: {:?}", scores);
}

Those two access styles in the middle — scores[0] and scores.get(100) — are not stylistic alternatives. Indexing panics when the position does not exist; .get() returns an Option so you can decide what absence means. HashMap offers exactly the same pair, and the panic message it produces is much less obvious. Before meeting it, recall what that Option return buys you.

Recall

Without scrolling up: in Option and Result you learned that Option<T> makes absence part of the type, so the compiler will not let you use a value without deciding what happens when it is missing. Apply that to scores[0] versus scores.get(0). What is the real difference between them?

Both check at run time; the difference is what they hand back. Indexing promises a &T, and the only way to keep that promise when the element is missing is to crash — so it panics. .get() promises Option<&T>, which forces the absent case into the open before you can touch the value. Pick by intent: index when an out-of-range access would mean your own code is broken, and .get() when a missing element is legitimate input you must handle. The next program gets that choice wrong, in the HashMap form where the panic message is least helpful. Commit a hypothesis before you change anything:

Debug

total_for should sum the quantities of the requested parts, counting anything unstocked as 0. The first call works and the second crashes. Say exactly which line panics and why the message says what it says, then fix it so both calls report 14.

use std::collections::HashMap;

/// Sum the quantities of the requested parts.
/// A part that is not stocked counts as 0 and must NOT stop the report.
fn total_for(stock: &HashMap<&str, u32>, wanted: &[&str]) -> u32 {
  let mut total = 0;
  for part in wanted {
      total += stock[part];
  }
  total
}

fn main() {
  let mut stock: HashMap<&str, u32> = HashMap::new();
  stock.insert("bolts", 10);
  stock.insert("nuts", 4);

  let stocked = ["bolts", "nuts"];
  let with_gap = ["bolts", "washers", "nuts"];

  println!("stocked  -> {}", total_for(&stock, &stocked));
  println!("with gap -> {}", total_for(&stock, &with_gap));

  assert_eq!(total_for(&stock, &stocked), 14, "both stocked parts should total 14");
  assert_eq!(total_for(&stock, &with_gap), 14, "an unstocked part counts as 0, not a crash");
  println!("All checks passed.");
}

Expected output: stocked -> 14 with gap -> 14 All checks passed.

The panic is on total += stock[part];. Indexing a HashMap goes through its Index impl, which promises a &u32 — so with no entry to return it panics with no entry found for key, a message that names neither the key nor anything about your logic. The first call survives only because every requested part happened to be stocked. The fix states the doc comment's actual intent in code: total += stock.get(part).copied().unwrap_or(0);get hands back an Option<&u32>, copied turns that into an owned Option<u32>, and unwrap_or(0) puts the policy for a missing part in one visible place.

Iterating Over Vectors

Vectors support multiple ways to iterate, each suited to different situations:

fn main() {
    let names = vec![
        String::from("Alice"),
        String::from("Bob"),
        String::from("Charlie"),
    ];

    // Immutable iteration with a reference
    println!("== All names ==");
    for name in &names {
        println!("  {}", name);
    }

    // Mutable iteration
    let mut prices = vec![10.0, 20.0, 30.0, 40.0];
    for price in &mut prices {
        *price *= 1.1; // Apply 10% increase
    }
    println!("Updated prices: {:?}", prices);

    // Consuming iteration (takes ownership)
    let numbers = vec![1, 2, 3, 4, 5];
    let total: i32 = numbers.into_iter().sum();
    println!("Sum: {}", total);
    // numbers is no longer available here because into_iter consumed it
}

Iterator Adaptors: map, filter, and More

Iterators in Rust are lazy -- they do nothing until you consume them. Adaptor methods like .map() and .filter() transform iterators, while consuming methods like .collect() and .sum() produce final results:

fn main() {
    let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

    // filter: keep only even numbers
    let evens: Vec<i32> = numbers.iter()
        .filter(|&&n| n % 2 == 0)
        .cloned()
        .collect();
    println!("Evens: {:?}", evens);

    // map: transform each element
    let doubled: Vec<i32> = numbers.iter()
        .map(|&n| n * 2)
        .collect();
    println!("Doubled: {:?}", doubled);

    // Chaining: filter then map
    let large_squares: Vec<i32> = numbers.iter()
        .filter(|&&n| n > 5)
        .map(|&n| n * n)
        .collect();
    println!("Squares of numbers > 5: {:?}", large_squares);

    // find: get the first match
    let first_even = numbers.iter().find(|&&n| n % 2 == 0);
    println!("First even: {:?}", first_even);

    // any and all: boolean checks
    let has_negative = numbers.iter().any(|&n| n < 0);
    let all_positive = numbers.iter().all(|&n| n > 0);
    println!("Has negative: {}, All positive: {}", has_negative, all_positive);
}

HashMap<K, V>

A HashMap stores key-value pairs with O(1) average lookup time. Keys must implement the Eq and Hash traits. Most standard types like String, integers, and booleans work as keys:

use std::collections::HashMap;

fn main() {
    let mut book_ratings: HashMap<String, f64> = HashMap::new();

    // Insert entries
    book_ratings.insert(String::from("The Rust Book"), 4.8);
    book_ratings.insert(String::from("Programming Rust"), 4.7);
    book_ratings.insert(String::from("Rust in Action"), 4.5);

    // Access a value
    let title = "The Rust Book";
    if let Some(rating) = book_ratings.get(title) {
        println!("{}: {}/5.0", title, rating);
    }

    // Update: insert overwrites existing values
    book_ratings.insert(String::from("The Rust Book"), 4.9);

    // Update only if key does not exist
    book_ratings
        .entry(String::from("Zero to Production"))
        .or_insert(4.6);

    // Iterate over key-value pairs
    println!("\nAll ratings:");
    for (book, rating) in &book_ratings {
        println!("  {}: {}", book, rating);
    }

    // Check if a key exists
    println!(
        "\nHas 'The Rust Book': {}",
        book_ratings.contains_key("The Rust Book")
    );
    println!("Total books: {}", book_ratings.len());
}

Counting with HashMap

A very common pattern is using a HashMap to count occurrences. The entry API makes this clean and efficient:

use std::collections::HashMap;

fn main() {
    let text = "the quick brown fox jumps over the lazy dog the fox";
    let mut word_counts: HashMap<&str, u32> = HashMap::new();

    for word in text.split_whitespace() {
        let count = word_counts.entry(word).or_insert(0);
        *count += 1;
    }

    // Sort by count (descending) for display
    let mut counts_vec: Vec<(&&str, &u32)> = word_counts.iter().collect();
    counts_vec.sort_by(|a, b| b.1.cmp(a.1));

    println!("Word frequencies:");
    for (word, count) in counts_vec {
        println!("  {:>6}: {}", word, count);
    }
}

Four ways of putting a value into a HashMap appear in this lesson, and they behave differently in ways the method names only half suggest. Trace the program below — the closure in the last one prints, so you can see whether it runs at all.

Predict

Four writes hit the same map: insert on an existing key, or_insert on an existing key, or_insert on a new key followed by a mutation through the returned reference, and or_insert_with on an existing key. What are the three printed lines, and does ' [closure ran]' appear?

use std::collections::HashMap;

fn main() {
  let mut stock: HashMap<&str, i32> = HashMap::new();
  stock.insert("bolts", 10);

  // A: insert on an existing key
  stock.insert("bolts", 4);

  // B: entry().or_insert on an existing key
  stock.entry("bolts").or_insert(99);

  // C: entry().or_insert on a NEW key, then mutate through the returned &mut
  let nuts = stock.entry("nuts").or_insert(0);
  *nuts += 7;

  // D: or_insert_with on an existing key — is the closure even called?
  stock.entry("bolts").or_insert_with(|| {
      println!("  [closure ran]");
      1000
  });

  println!("bolts = {:?}", stock.get("bolts"));
  println!("nuts  = {:?}", stock.get("nuts"));
  println!("count = {}", stock.len());
}

bolts = Some(4), nuts = Some(7), count = 2, and the closure never runs. insert writes unconditionally, so line A replaces the 10 with 4. or_insert writes only when the entry is vacant — line B finds bolts occupied and leaves it alone — but it always returns a &mut to whatever is there afterwards, which is what makes line C's counting idiom work: *nuts += 7 writes straight into the map with no second lookup. or_insert_with takes a closure rather than a value, so an occupied entry means the default is never even computed; reach for it when the default is expensive (a Vec::new, an allocation, a parse) and for or_insert when it is a plain literal.

HashSet<T>

A HashSet is a collection of unique values. It is essentially a HashMap where you only care about the keys. HashSets are great for membership testing and set operations:

use std::collections::HashSet;

fn main() {
    let mut frontend: HashSet<&str> = HashSet::new();
    frontend.insert("Alice");
    frontend.insert("Bob");
    frontend.insert("Charlie");
    frontend.insert("Alice"); // Duplicate, will be ignored

    let mut backend: HashSet<&str> = HashSet::new();
    backend.insert("Bob");
    backend.insert("Diana");
    backend.insert("Eve");

    println!("Frontend team: {:?}", frontend);
    println!("Backend team: {:?}", backend);

    // Set operations
    let fullstack: HashSet<&&str> = frontend.intersection(&backend).collect();
    println!("Fullstack (both teams): {:?}", fullstack);

    let all_devs: HashSet<&&str> = frontend.union(&backend).collect();
    println!("All developers: {:?}", all_devs);

    let frontend_only: HashSet<&&str> = frontend.difference(&backend).collect();
    println!("Frontend only: {:?}", frontend_only);

    // Membership testing
    println!("Is Alice on frontend? {}", frontend.contains("Alice"));
    println!("Is Diana on frontend? {}", frontend.contains("Diana"));
}

Building Collections with Iterators

Iterators and collections work together seamlessly. You can transform data from one collection type to another using .collect():

use std::collections::HashMap;

fn main() {
    // Create a HashMap from two vectors using zip
    let keys = vec!["name", "language", "level"];
    let values = vec!["Rustacean", "Rust", "Intermediate"];

    let profile: HashMap<&str, &str> = keys.into_iter()
        .zip(values.into_iter())
        .collect();
    println!("Profile: {:?}", profile);

    // Partition a vector into two based on a predicate
    let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
    let (evens, odds): (Vec<i32>, Vec<i32>) = numbers.into_iter()
        .partition(|&n| n % 2 == 0);
    println!("Evens: {:?}", evens);
    println!("Odds: {:?}", odds);

    // Enumerate: get index alongside value
    let fruits = vec!["apple", "banana", "cherry"];
    let indexed: Vec<(usize, &&str)> = fruits.iter()
        .enumerate()
        .filter(|(i, _)| i % 2 == 0)
        .collect();
    println!("Even-indexed fruits: {:?}", indexed);
}

Grouping with a Map of Vecs

A HashMap whose values are Vecs is how you group records by key, and the entry API is what makes it readable: or_insert_with(Vec::new) builds the empty Vec only for a key you have not seen, and hands back a &mut Vec<f64> either way — so the .push() chained onto it appends in place. Notice the other thing this example has to do: it sorts the keys before displaying anything, because a HashMap's iteration order is unspecified and would otherwise shuffle the report between runs.

use std::collections::HashMap;

fn letter_grade(score: f64) -> &'static str {
    match score as u32 {
        90..=100 => "A",
        80..=89 => "B",
        70..=79 => "C",
        60..=69 => "D",
        _ => "F",
    }
}

fn main() {
    let mut gradebook: HashMap<&str, Vec<f64>> = HashMap::new();

    // or_insert_with(Vec::new) builds the empty Vec only for a NEW student,
    // and returns a &mut Vec<f64> either way — so .push() appends in place.
    for (student, score) in [
        ("Alice", 92.0), ("Alice", 88.0), ("Alice", 95.0),
        ("Bob", 78.0), ("Bob", 82.0), ("Bob", 71.0),
        ("Charlie", 65.0), ("Charlie", 70.0), ("Charlie", 58.0),
    ] {
        gradebook.entry(student).or_insert_with(Vec::new).push(score);
    }

    // Sort before displaying: HashMap iteration order is NOT stable.
    let mut students: Vec<&&str> = gradebook.keys().collect();
    students.sort();

    println!("{:<10} {:>8} {:>6}", "Student", "Average", "Grade");
    println!("{}", "-".repeat(26));

    let mut best: Option<(&str, f64)> = None;
    for student in students {
        let scores = &gradebook[student];
        let average = scores.iter().sum::<f64>() / scores.len() as f64;
        println!("{:<10} {:>8.1} {:>6}", student, average, letter_grade(average));

        if best.map_or(true, |(_, top)| average > top) {
            best = Some((student, average));
        }
    }

    if let Some((name, avg)) = best {
        println!("\nTop student: {} with {:.1} average", name, avg);
    }
}

That prints Alice at 91.7 (an A), Bob at 77.0 (a C) and Charlie at 64.3 (a D), in that order, on every run — the sorted key list is what makes "in that order" true. Note also that gradebook[student] is safe here for the reason the retrieval above named: the keys came out of the map itself, so an absent one would mean this code is broken, not that the input was unusual.

Try It Yourself

Reading about the entry API is not the same as reaching for it by intent. This is a build task: a small program that reports its own pass/fail. Three functions are stubbed out — one builds a frequency map, one reads it safely, and one renders it in a stable order. Run the starter as-is and it fails immediately, naming the first check that did not pass. Implement each until every check passes and it prints All checks passed.

The third function looks like busywork and is not. A HashMap's iteration order is deliberately unspecified and varies between runs of the same binary, because the hasher is seeded randomly at start-up to make collision attacks impractical. Any output derived from iterating a map must therefore be sorted before it is compared or displayed — which is exactly what the last check asserts.

Build

Finish the build. Three functions are stubbed out and the checks below them fail until each one returns the right value. Run it as-is to see which check fails first, decide what that function is missing, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.

// A tiny word-frequency workbench built on HashMap.
use std::collections::HashMap;

// TODO 1: count how many times each word appears.
//   tally(&["a", "b", "a"]) has 2 entries: "a" -> 2 and "b" -> 1.
//   Use the entry API: entry(key).or_insert(0) hands you a &mut u32 for
//   that key, inserting the 0 first if the key was absent. Then *count += 1.
fn tally(words: &[&str]) -> HashMap<String, u32> {
  let _ = words;
  HashMap::new()
}

// TODO 2: return the count for word, or 0 if it was never seen.
//   This must NOT panic on an absent word — index syntax counts[word] would.
//   get() returns Option<&u32>; copied() turns that into Option<u32>.
fn count_of(counts: &HashMap<String, u32>, word: &str) -> u32 {
  let _ = counts;
  let _ = word;
  0
}

// TODO 3: return every (word, count) pair SORTED by word.
//   Collect the map's pairs into a Vec, then sort it. The sort is the point:
//   HashMap iteration order is not stable, so an unsorted Vec here would give
//   a different answer on different runs.
fn sorted_pairs(counts: &HashMap<String, u32>) -> Vec<(String, u32)> {
  let _ = counts;
  Vec::new()
}

// --- Build checks: these must all pass. Do not edit below this line. ---
fn main() {
  let words = ["fig", "date", "fig", "elder", "fig", "date"];
  let counts = tally(&words);

  assert_eq!(counts.len(), 3, "tally should hold one entry per DISTINCT word");
  assert_eq!(count_of(&counts, "fig"), 3, "fig appears three times");
  assert_eq!(count_of(&counts, "date"), 2, "date appears twice");
  assert_eq!(count_of(&counts, "quince"), 0, "an absent word counts as 0, not a panic");

  assert_eq!(
      sorted_pairs(&counts),
      vec![
          ("date".to_string(), 2),
          ("elder".to_string(), 1),
          ("fig".to_string(), 3),
      ],
      "sorted_pairs must sort, because HashMap iteration order is not stable"
  );

  println!("All checks passed.");
  println!("Distinct words: {}", counts.len());
  println!("Sorted: {:?}", sorted_pairs(&counts));
  println!("fig: {}, quince: {}", count_of(&counts, "fig"), count_of(&counts, "quince"));
}

Expected output: All checks passed. Distinct words: 3 Sorted: [("date", 2), ("elder", 1), ("fig", 3)] fig: 3, quince: 0

Once it passes, try two variations and predict each before running:

  1. Replace the entry call with a plain insert. In tally, swap the two-line body of the loop for counts.insert(word.to_string(), 1);. Predict which check breaks before running. The first check still passes — there are still three distinct keys — and the second fails with left: 1, right: 3. insert overwrites unconditionally, so every repeat of "fig" resets its count to 1 rather than adding to it. The distinct-key count looks right, which is exactly why this bug survives a casual glance.
  2. Delete the sort. Remove pairs.sort(); from sorted_pairs. Predict what happens before running — and then run it several times. It is not a stable failure: on most runs the last check fails with the pairs in some scrambled order like [("fig", 3), ("elder", 1), ("date", 2)], but every so often the map's random order happens to match and the whole program passes. A test that passes intermittently is worse than one that always fails, and this is the mechanism behind a great many of them.

Key Takeaways

  • Vec<T> is Rust's growable array, the most commonly used collection
  • HashMap<K, V> provides fast key-value lookups with the entry API for elegant updates
  • HashSet<T> stores unique values and supports set operations like union and intersection
  • Iterators are lazy and must be consumed with methods like .collect(), .sum(), or .for_each()
  • Adaptor methods like .map(), .filter(), and .enumerate() transform iterators without consuming them
  • .collect() can build any collection type -- the type annotation tells Rust which one you want

Pro Tip: When all you need is to look up values by a key, HashMap is the default choice. When you need to check "is this element in the set?", use HashSet. When order matters, consider BTreeMap and BTreeSet, which keep keys sorted.

Next Steps

Now that you can work with collections, you're ready to tackle lifetimes — Rust's way of ensuring references are always valid, even across complex data structures.

Next lesson

Lifetimes

Rust lifetimes explained — learn lifetime annotations, elision rules, and how the borrow checker validates reference validity

30 min