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);
}

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);
    }
}

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);
}

Try It Yourself

Build a simple student grade tracker using vectors and hashmaps. Try adding more students or changing the grading logic:

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();

    // Add scores for students
    gradebook.entry("Alice").or_insert_with(Vec::new).push(92.0);
    gradebook.entry("Alice").or_insert_with(Vec::new).push(88.0);
    gradebook.entry("Alice").or_insert_with(Vec::new).push(95.0);

    gradebook.entry("Bob").or_insert_with(Vec::new).push(78.0);
    gradebook.entry("Bob").or_insert_with(Vec::new).push(82.0);
    gradebook.entry("Bob").or_insert_with(Vec::new).push(71.0);

    gradebook.entry("Charlie").or_insert_with(Vec::new).push(65.0);
    gradebook.entry("Charlie").or_insert_with(Vec::new).push(70.0);
    gradebook.entry("Charlie").or_insert_with(Vec::new).push(58.0);

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

    for (student, scores) in &gradebook {
        let average = scores.iter().sum::<f64>() / scores.len() as f64;
        let grade = letter_grade(average);
        println!("{:<10} {:>8.1} {:>6}", student, average, grade);
    }

    // Find the top student
    let top_student = gradebook.iter()
        .map(|(name, scores)| {
            let avg = scores.iter().sum::<f64>() / scores.len() as f64;
            (name, avg)
        })
        .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap());

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

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 you need to look up values by a key, always reach for HashMap. 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