The Rust Collections Guide

Collections in Rust: Core Ideas and Selection

Last Updated: 2026-04-05

Why collections matter

Collections are the main way Rust programs work with groups of values. A single integer or struct is useful, but real programs usually need to store many items, search through them, transform them, or pass them between parts of a system. In Rust, collections are also where ownership, borrowing, allocation, and performance become concrete rather than abstract.

A useful way to think about a collection is that it answers a shape-of-data question. Do you need a growable ordered sequence? A fast key-value lookup table? A set of unique items? A fixed amount of contiguous storage? Different collections represent different answers.

The standard library collections are deliberately small in number. That is a strength. Instead of memorizing dozens of containers, it is better to learn the tradeoffs of a few central ones deeply: Vec<T>, String, HashMap<K, V>, BTreeMap<K, V>, HashSet<T>, BTreeSet<T>, and VecDeque<T>. Arrays and slices are also foundational even though they are not usually introduced as heap collections.

This guide focuses on the mental model you need before studying each collection in depth. The goal is not just to know what exists, but to understand why one collection fits a problem better than another.

Owned values, borrowed views, and collection APIs

Rust collections interact constantly with ownership. Some collections own their contents, while other types give borrowed views into existing data. This distinction affects function signatures, mutation, lifetimes, and whether extra allocation happens.

A Vec<T> owns its elements. A slice like &[T] borrows a contiguous region of elements from somewhere else. A String owns UTF-8 bytes. A &str borrows a UTF-8 string slice.

A beginner mistake is to think of these as interchangeable. They are related, but they serve different roles. Owned collections manage storage. Borrowed views let code inspect or modify data without taking ownership of it.

fn print_numbers(values: &[i32]) {
    for value in values {
        println!("{value}");
    }
}
 
fn main() {
    let numbers = vec![10, 20, 30];
    print_numbers(&numbers);
 
    let array = [1, 2, 3, 4];
    print_numbers(&array);
}

This is a strong API because the function accepts any borrowed contiguous sequence of i32 values, not only a Vec<i32>. In practice, collection-oriented APIs often become more flexible when they accept borrowed forms such as &[T], &str, or iterators rather than concrete owned containers.

The reverse is also important. When a function must create new storage and return it to the caller, returning an owned collection is often the clearest design.

fn even_squares(input: &[i32]) -> Vec<i32> {
    input
        .iter()
        .copied()
        .filter(|n| n % 2 == 0)
        .map(|n| n * n)
        .collect()
}

This function borrows its input but returns a newly owned vector. That pattern appears constantly in Rust.

Stack data, heap data, and where collections live

Not every group of values is a heap collection. Arrays like [T; N] have a fixed size known at compile time. They are usually stored inline, which often means stack allocation for local variables. By contrast, Vec<T> stores a small control structure inline but places its element buffer on the heap.

This distinction matters because heap allocation enables dynamic size but has costs. It may involve allocation, reallocation, pointer indirection, and different cache behavior. Stack or inline storage can be simpler and faster when the size is fixed and small.

fn main() {
    let fixed: [i32; 4] = [1, 2, 3, 4];
    let dynamic: Vec<i32> = vec![1, 2, 3, 4];
 
    println!("array length = {}", fixed.len());
    println!("vec length = {}", dynamic.len());
}

Both represent sequences, but they answer different requirements. Use an array when the size is inherently fixed. Use a vector when the size must grow, shrink, or be built at runtime.

A practical rule is this: prefer fixed-size representations when the problem is naturally fixed-size, and prefer heap-backed collections when the problem is naturally variable-size. Do not choose Vec<T> only because it feels more general.

The shared vocabulary of collections

Most Rust collections can be understood through a small set of recurring operations. Learning these concepts first makes it easier to move between specific types.

Insertion adds elements. Removal takes elements out. Lookup finds existing elements by position or key. Iteration visits elements in sequence. Transformation builds a new result from existing values. Filtering keeps only some elements. Extension appends data from another source.

For example, many collections support iteration, but not all support indexing. A Vec<T> supports positional indexing because it is contiguous. A HashMap<K, V> supports key-based lookup instead. A HashSet<T> does not store values with separate payloads at all; it is mainly about membership.

use std::collections::{HashMap, HashSet};
 
fn main() {
    let mut scores = HashMap::new();
    scores.insert("alice", 10);
    scores.insert("bob", 20);
 
    let mut seen = HashSet::new();
    seen.insert("alice");
    seen.insert("carol");
 
    println!("bob => {:?}", scores.get("bob"));
    println!("seen alice => {}", seen.contains("alice"));
}

The collection types differ, but the usage questions are familiar. How do I add something? How do I test for membership? How do I iterate? How expensive is each operation likely to be? Those questions are more important than memorizing method lists.

Choosing by access pattern rather than by habit

The best first-pass way to select a collection is to ask what your code will do most often.

If you mostly append items, iterate over them in order, and occasionally index by position, start with Vec<T>. If you need efficient insertion and removal at both ends, consider VecDeque<T>. If you need lookup by key, use a map. If you need membership and uniqueness, use a set. If you need sorted keys or ordered traversal, prefer a B-tree collection.

A poor selection process starts with the collection name. A better one starts with the workload. Consider these examples.

A log collector that accumulates events before flushing them is usually a Vec<Event>. A frequency counter keyed by words is naturally a HashMap<String, usize>. A list of enabled feature names where order matters for display might be a BTreeSet<String> if deterministic ordering is useful.

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
}

This is not just a map because maps are common. It is a map because the dominant operation is updating and retrieving values by key.

In many real programs, starting with Vec<T> is reasonable because it is simple, cache-friendly, and versatile. But that should be a provisional choice, not a reflex.

The major standard collections and what they are for

The central Rust collection types each have a clear role.

Vec<T> is the general-purpose growable sequence. It is the default choice for ordered data.

String is an owned growable UTF-8 text buffer. It is to text what Vec<u8> is to raw bytes, except with UTF-8 validity guarantees.

VecDeque<T> is a double-ended queue backed by a ring buffer. It is useful when both front and back operations matter.

HashMap<K, V> stores key-value pairs with fast average-case lookup.

BTreeMap<K, V> stores key-value pairs in sorted order and supports ordered traversal and range queries.

HashSet<T> stores unique values with fast average-case membership tests.

BTreeSet<T> stores unique values in sorted order.

[T; N] and slices like &[T] are not usually listed alongside heap collections, but they are essential because many APIs are better expressed in terms of borrowed contiguous data.

use std::collections::{BTreeSet, HashSet};
 
fn main() {
    let mut fast = HashSet::new();
    fast.insert("pear");
    fast.insert("apple");
    fast.insert("orange");
 
    let mut ordered = BTreeSet::new();
    ordered.insert("pear");
    ordered.insert("apple");
    ordered.insert("orange");
 
    println!("hash set iteration:");
    for item in &fast {
        println!("{item}");
    }
 
    println!("btree set iteration:");
    for item in &ordered {
        println!("{item}");
    }
}

The BTreeSet iteration order is sorted. The HashSet iteration order is not something you should depend on. That one difference already tells you a lot about when each is appropriate.

A practical selection guide

When you do not yet know the perfect collection, a rough selection guide helps.

Choose Vec<T> when you need a growable ordered collection and do not have strong reasons to choose something else.

Choose &[T] when you need to read or process a contiguous sequence without taking ownership.

Choose String for owned text and &str for borrowed text.

Choose HashMap<K, V> when you need fast key-based lookup and order does not matter.

Choose BTreeMap<K, V> when you need keys in sorted order, stable iteration order, or range queries.

Choose HashSet<T> when you care about uniqueness and fast membership testing.

Choose BTreeSet<T> when you care about uniqueness and sorted traversal.

Choose VecDeque<T> when your algorithm naturally pushes and pops at both ends.

Be cautious with LinkedList<T>. It exists, but in Rust it is rarely the best answer. Many workloads that appear to want a linked list are better served by Vec<T> or VecDeque<T> because contiguous memory tends to perform well and integrates more cleanly with the rest of the ecosystem.

use std::collections::VecDeque;
 
fn bfs_frontier_demo() {
    let mut queue = VecDeque::new();
    queue.push_back("start");
    queue.push_back("next");
 
    while let Some(node) = queue.pop_front() {
        println!("visiting {node}");
    }
}

This pattern fits VecDeque<T> because the algorithm repeatedly removes from the front and adds to the back. Using Vec<T> here would work functionally, but repeated front removal would be a bad fit.

Big-O matters, but actual behavior matters too

Collection choice is often introduced through asymptotic complexity, and that is useful, but it is incomplete. Real performance is shaped by memory layout, cache locality, allocation frequency, hashing cost, ordering requirements, and constant factors.

A Vec<T> is often extremely fast because its elements are contiguous in memory. Even when another data structure offers appealing abstract properties, the vector can still win in practice for moderate sizes because linear scans over contiguous data are efficient.

Similarly, a hash map may have excellent average-case lookup complexity, but that does not mean it is always the right choice. If you need deterministic sorted output, a BTreeMap<K, V> may better match the problem even if individual lookups have different tradeoffs.

The right lesson is not to ignore Big-O. It is to combine Big-O with actual access pattern, output requirements, and data size.

fn contains_linear(haystack: &[i32], needle: i32) -> bool {
    haystack.contains(&needle)
}

For a small list, this can be entirely reasonable. Converting everything into a set just because set membership is theoretically attractive can complicate code and allocate unnecessarily.

Good selection is contextual. A collection is not optimal in the abstract; it is optimal for a workload.

API design: accept flexible inputs, return clear outputs

One of the most useful collection habits in Rust is to accept borrowed or abstract inputs when possible and return owned outputs when appropriate. This makes APIs easier to call and reduces needless allocation.

For example, a function that only needs read access to a sequence should usually not require Vec<T>. Requiring ownership unnecessarily makes callers clone or restructure data. On the other hand, a function that constructs a new result should often return an owned type such as Vec<T> or String.

fn join_with_commas(parts: &[&str]) -> String {
    parts.join(",")
}
 
fn main() {
    let a = ["red", "green", "blue"];
    let v = vec!["circle", "square"];
 
    println!("{}", join_with_commas(&a));
    println!("{}", join_with_commas(&v));
}

The input is flexible because the function borrows a slice of string slices. The output is owned because the function creates a new string.

This style scales well. It leads to APIs that are easier to reuse and easier to compose. It also keeps ownership choices visible and intentional.

A small decision checklist

When choosing a collection, ask a small number of direct questions.

Do I need ownership or only a borrowed view?

Is the size fixed or variable?

Do I care about order, sortedness, uniqueness, or key-based lookup?

Will I mostly append, search, iterate, or remove from the front?

Do I need deterministic iteration order?

Am I optimizing for simplicity first, or do I already know the workload justifies something more specialized?

A useful default is to begin with the simplest collection that matches the semantics of the problem. In many cases that means Vec<T>, slices, String, or HashMap<K, V>. Then refine only when the problem actually demands a different shape.

cargo new collections-core-ideas
cd collections-core-ideas
cargo run

As you study Rust collections, try small experiments instead of memorizing definitions. Create a vector, a map, and a set. Insert data, iterate, borrow them through functions, and observe which designs feel natural. Collection fluency develops fastest when you connect the type to the problem shape it is meant to solve.

A tiny sandbox project for experimenting

The following Cargo.toml is enough for a small playground crate where you can compare collection behavior. No external dependencies are required.

[package]
name = "collections-core-ideas"
version = "0.1.0"
edition = "2024"

A minimal src/main.rs could look like this.

use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
 
fn main() {
    let numbers = vec![1, 2, 3, 4];
    inspect_slice(&numbers);
 
    let mut queue = VecDeque::new();
    queue.push_back("a");
    queue.push_back("b");
    println!("front = {:?}", queue.pop_front());
 
    let mut counts = HashMap::new();
    counts.insert("apple", 3);
    counts.insert("pear", 2);
    println!("apple count = {:?}", counts.get("apple"));
 
    let mut ordered = BTreeMap::new();
    ordered.insert(2, "b");
    ordered.insert(1, "a");
    println!("ordered keys:");
    for key in ordered.keys() {
        println!("{key}");
    }
 
    let unique: HashSet<_> = ["red", "blue", "red"].into_iter().collect();
    println!("unique size = {}", unique.len());
}
 
fn inspect_slice(values: &[i32]) {
    println!("values = {:?}", values);
}

This one program already demonstrates several core ideas. Borrowed sequence access through slices. Double-ended queue behavior. Key-value lookup. Ordered versus unordered structures. Uniqueness through a set. That is the right level of experimentation for the beginning of a collections guide.