The Rust Collections Guide
Collection Patterns in Applications
Last Updated: 2026-04-05
Why application patterns matter
Learning individual collection types is only the first step. Real programs are usually not about storing values in the abstract. They are about recurring tasks such as counting events, grouping records, deduplicating inputs, building indexes, mutating data in place, or organizing nested structures.
This is where collections become practical rather than merely descriptive. The important question shifts from "what does HashMap<K, V> do?" to "what collection shape matches the data flow of this application task?"
A useful way to study collections at this stage is through patterns rather than types. Grouping, counting, bucketing, deduplication, and index-building appear across web services, command-line tools, data pipelines, parsers, schedulers, and reporting systems. Once you can recognize these shapes, collection choice becomes much easier.
Counting with maps
Counting is one of the most common real-world collection patterns. The key usually identifies what is being counted, and the value stores the count. This naturally maps to HashMap<K, usize> or BTreeMap<K, usize>.
use std::collections::HashMap;
fn word_counts(text: &str) -> HashMap<String, usize> {
let mut counts = HashMap::new();
for word in text.split_whitespace() {
*counts.entry(word.to_string()).or_insert(0) += 1;
}
counts
}
fn main() {
let counts = word_counts("pear apple pear banana apple pear");
println!("counts = {:?}", counts);
}This pattern appears everywhere: counting log levels, tracking request paths, tallying tags, measuring frequencies, and summarizing input streams. The entry API is what makes it concise and natural.
Grouping values by key
Grouping is closely related to counting, but instead of storing a number for each key, you store a collection of matching items. A very common shape is HashMap<K, Vec<V>> or BTreeMap<K, Vec<V>>.
use std::collections::BTreeMap;
fn main() {
let items = ["pear", "plum", "apple", "kiwi", "banana"];
let mut by_length: BTreeMap<usize, Vec<&str>> = BTreeMap::new();
for item in items {
by_length.entry(item.len()).or_default().push(item);
}
for (len, words) in &by_length {
println!("{len} => {:?}", words);
}
}Grouping is useful when you need categories, partitions, or rollups. It shows up in report generation, batching, indexing, and classification tasks.
Bucketing into ranges or categories
Not all grouping keys come directly from the data. Sometimes you compute a bucket from each value. That is a common application pattern when summarizing numeric values, ratings, ages, timestamps, or priorities.
use std::collections::BTreeMap;
fn bucket(score: u32) -> &'static str {
match score {
0..=49 => "low",
50..=79 => "medium",
_ => "high",
}
}
fn main() {
let scores = [12, 88, 64, 42, 91, 77, 55];
let mut buckets: BTreeMap<&str, Vec<u32>> = BTreeMap::new();
for score in scores {
buckets.entry(bucket(score)).or_default().push(score);
}
println!("buckets = {:?}", buckets);
}The important idea is that the collection is driven by the domain question, not just by the raw input shape. The bucket key is often derived rather than stored directly in the original data.
Building indexes for fast lookup
Another common application pattern is building an index: a map from some key to the thing that should be found quickly later. This is common when loading records into memory, parsing configuration, or preparing data for repeated queries.
use std::collections::HashMap;
#[derive(Debug)]
struct User {
id: u32,
name: String,
}
fn main() {
let users = vec![
User { id: 1, name: "Alice".to_string() },
User { id: 2, name: "Bob".to_string() },
User { id: 3, name: "Carol".to_string() },
];
let by_id: HashMap<u32, &User> = users.iter().map(|user| (user.id, user)).collect();
println!("user 2 = {:?}", by_id.get(&2));
}This pattern is very common in applications that ingest a list once and then answer many keyed queries efficiently.
Deduplication with sets
Deduplication is one of the clearest cases where a set is the right abstraction. If the main question is whether something has been seen already, HashSet<T> or BTreeSet<T> often matches the problem directly.
use std::collections::HashSet;
fn main() {
let input = ["a", "b", "a", "c", "b", "d"];
let unique: HashSet<_> = input.into_iter().collect();
println!("unique = {:?}", unique);
}This is useful for removing duplicate identifiers, tracking visited nodes, collapsing repeated tags, or ensuring uniqueness constraints during processing.
Deduplication while preserving first-seen order
Sometimes uniqueness matters, but the original encounter order matters too. A plain set alone is not enough, because sets do not preserve insertion order in the standard library. A common pattern is to combine a HashSet<T> with a Vec<T>.
use std::collections::HashSet;
fn dedup_preserve_order(input: &[&str]) -> Vec<&str> {
let mut seen = HashSet::new();
let mut out = Vec::new();
for item in input {
if seen.insert(*item) {
out.push(*item);
}
}
out
}
fn main() {
let values = ["pear", "apple", "pear", "banana", "apple"];
println!("{:?}", dedup_preserve_order(&values));
}This pattern is common when preparing display lists, cleaning user input, or preserving stream order while removing repeats.
Nested collections and how they arise
Nested collections appear naturally in real programs. A map of vectors can represent grouping. A vector of maps can represent records. A map of sets can represent relationships or permissions. The question is not whether nesting is allowed, but whether the nesting matches the domain clearly.
use std::collections::{HashMap, HashSet};
fn main() {
let mut permissions: HashMap<&str, HashSet<&str>> = HashMap::new();
permissions.entry("alice").or_default().insert("read");
permissions.entry("alice").or_default().insert("write");
permissions.entry("bob").or_default().insert("read");
println!("permissions = {:?}", permissions);
}This pattern is strong because the meaning is direct: each user maps to a set of unique permissions. Nested collections become easier to reason about when each layer has a clear semantic role.
Counting within groups
Sometimes applications need both grouping and counting. That often leads to nested maps such as HashMap<K, HashMap<L, usize>>. This is common in analytics, reporting, and event summarization.
use std::collections::HashMap;
fn main() {
let events = [
("alice", "view"),
("alice", "click"),
("bob", "view"),
("alice", "view"),
("bob", "click"),
];
let mut counts: HashMap<&str, HashMap<&str, usize>> = HashMap::new();
for (user, action) in events {
*counts
.entry(user)
.or_default()
.entry(action)
.or_insert(0) += 1;
}
println!("counts = {:?}", counts);
}This looks more complex than a single map, but the structure follows the problem cleanly: first by user, then by action, then by count.
In-place filtering with `retain`
A common application task is to remove items that no longer satisfy some condition. When you want to mutate a collection in place rather than build a new one, retain is often the right tool.
fn main() {
let mut values = vec![3, 8, 1, 12, 5, 20];
values.retain(|n| *n >= 8);
println!("retained = {:?}", values);
}This avoids manual index bookkeeping and makes the intention obvious. In-place filtering is useful for validation passes, cleanup logic, and state updates.
Mutation while iterating: the core issue
One of the most common collection mistakes is trying to mutate a collection structurally while iterating over it in a way that invalidates the iteration. Rust makes many of these mistakes impossible, which can feel restrictive at first but prevents subtle bugs.
For example, you usually cannot hold an iterator over a vector and also push new elements into the same vector during that loop. The issue is not arbitrary compiler strictness. The issue is that structural mutation can invalidate references or iteration state.
A useful rule is that if you need to change which items exist, you often want one of three patterns instead: in-place filtering with retain, collecting into a new output collection, or using a work queue.
Building a new collection instead of mutating in place
A common safe pattern is to iterate over existing data and build a new collection containing the desired result. This is often clearer than trying to edit the original structure while traversing it.
fn main() {
let input = vec![1, 2, 3, 4, 5, 6];
let output: Vec<i32> = input.into_iter().filter(|n| n % 2 == 0).map(|n| n * 10).collect();
println!("output = {:?}", output);
}This pattern is common in batch transforms, validation passes, response shaping, and data normalization.
Using `drain` when consuming part of a collection
Another useful pattern is drain, which removes a range of items while yielding them for further processing. This is especially helpful in batch-oriented application code.
fn main() {
let mut queue = vec!["a", "b", "c", "d", "e"];
let batch: Vec<_> = queue.drain(..3).collect();
println!("batch = {:?}", batch);
println!("remaining = {:?}", queue);
}This is useful for job batching, buffer draining, staged processing, and partial transfer of ownership from one collection to another.
A queue pattern for work expansion
Some application patterns need to consume work items and add more work as processing continues. That is usually better modeled as a queue than as mutation during direct iteration. VecDeque<T> is often the right structure.
use std::collections::VecDeque;
fn main() {
let mut work = VecDeque::new();
work.push_back(1);
while let Some(n) = work.pop_front() {
println!("processing {n}");
if n < 3 {
work.push_back(n + 1);
work.push_back(n + 10);
}
}
}This is a good pattern for breadth-first exploration, task expansion, retries, and staged workflows.
Partitioning data into two outputs
Applications often need to split data into two categories, such as valid and invalid, matched and unmatched, allowed and denied. One simple pattern is to build two collections in a single pass.
fn main() {
let values = [1, 2, 3, 4, 5, 6];
let mut even = Vec::new();
let mut odd = Vec::new();
for value in values {
if value % 2 == 0 {
even.push(value);
} else {
odd.push(value);
}
}
println!("even = {:?}", even);
println!("odd = {:?}", odd);
}This shows that not every application pattern needs a specialized method. Often the right collection structure becomes obvious once you name the categories clearly.
Merging and extending application state
Applications frequently need to merge newly produced data into existing collections. extend is often the simplest tool for this when items from one source should be appended or absorbed into another collection.
use std::collections::HashSet;
fn main() {
let mut active_users: HashSet<&str> = ["alice", "bob"].into_iter().collect();
let new_users = ["carol", "alice", "dave"];
active_users.extend(new_users);
println!("active_users = {:?}", active_users);
}This appears in sync logic, caching, state refreshes, ingestion steps, and many forms of incremental processing.
Using ordered versus unordered structures in reports
A common application decision is whether the output must be deterministic and ordered. During internal accumulation, HashMap<K, V> or HashSet<T> may be perfectly appropriate. But when producing user-facing or test-sensitive results, BTreeMap<K, V> or BTreeSet<T> may better express the intended semantics.
use std::collections::BTreeMap;
fn main() {
let mut counts = BTreeMap::new();
for word in ["pear", "apple", "pear", "banana"] {
*counts.entry(word).or_insert(0) += 1;
}
for (word, count) in &counts {
println!("{word}: {count}");
}
}This is especially useful for stable reports, logs, snapshots, and outputs that should not depend on arbitrary iteration order.
Choosing collection patterns by domain question
A helpful way to choose a pattern is to phrase the application need as a question.
If the question is "how many times did each thing occur?" then use counting.
If the question is "which things belong together?" then use grouping.
If the question is "have we seen this before?" then use a set.
If the question is "what should be found by this key later?" then build an index.
If the question is "which values remain after applying this rule?" then use filtering or retain.
If the question is "what work remains to be processed as new work appears?" then use a queue.
This shift from types to questions is what makes collection design practical in applications.
A small decision guide
Use HashMap<K, usize> or BTreeMap<K, usize> for counting.
Use HashMap<K, Vec<V>> or BTreeMap<K, Vec<V>> for grouping.
Use HashSet<T> or BTreeSet<T> for deduplication and membership.
Use nested collections when each layer has a clear semantic role, such as user-to-permission-set or group-to-count-map.
Use retain for in-place filtering, a new output collection for clean transforms, and VecDeque<T> when work expands as it is processed.
Prefer ordered tree-based collections when output order is part of the result's meaning.
A small sandbox project
A tiny Cargo project is enough to explore several application patterns side by side.
[package]
name = "collection-patterns-guide"
version = "0.1.0"
edition = "2024"Create and run it like this.
cargo new collection-patterns-guide
cd collection-patterns-guide
cargo runA minimal src/main.rs could look like this.
use std::collections::{HashMap, HashSet, VecDeque};
fn main() {
let text = "pear apple pear banana apple pear";
let mut counts = HashMap::new();
for word in text.split_whitespace() {
*counts.entry(word).or_insert(0) += 1;
}
println!("counts = {:?}", counts);
let input = ["a", "b", "a", "c", "b", "d"];
let mut seen = HashSet::new();
let mut ordered_unique = Vec::new();
for item in input {
if seen.insert(item) {
ordered_unique.push(item);
}
}
println!("ordered_unique = {:?}", ordered_unique);
let mut work = VecDeque::new();
work.push_back(1);
while let Some(n) = work.pop_front() {
println!("processing {n}");
if n < 3 {
work.push_back(n + 1);
}
}
}This one program demonstrates three of the most common application patterns: counting with a map, deduplicating while preserving first-seen order, and managing expanding work with a queue.
