Collections aren't created equal
Most Rust developers reach for a Vec without thinking twice, and for good reason. But when you're handling millions of records in a trading engine or managing cache layers in a ride-hailing platform, the difference between collections can mean the gap between a system that scales and one that doesn't. Rust collections performance: what developers should know is critical knowledge for anyone shipping production systems. The right data structure choice compounds across your codebase—small decisions multiply into measurable throughput gains.
Vec is usually fastest
We work with Vec because CPU caches love it. Vectors store elements contiguously in memory, which means modern processors can prefetch data and execute tight loops efficiently. When you're iterating, the CPU can predict memory access patterns and load entire cache lines at once.
Here's the practical reality: a tight loop over a vector of structs will outperform almost any other collection for sequential access. Consider a common scenario—processing transaction records:
struct Transaction {
id: u64,
amount: f64,
timestamp: u64,
}
fn process_transactions(txns: &[Transaction]) -> f64 {
txns.iter()
.filter(|t| t.timestamp > 1700000000)
.map(|t| t.amount)
.sum()
}
fn main() {
let mut txns = Vec::with_capacity(1_000_000);
for i in 0..1_000_000 {
txns.push(Transaction {
id: i,
amount: (i % 100) as f64,
timestamp: 1700000000 + i as u64,
});
}
let total = process_transactions(&txns);
println!("Total: {}", total);
}This pattern is faster than equivalent code using LinkedList or hand-rolled pointer structures because the memory layout is predictable. Allocation happens once upfront with with_capacity, and iteration is cache-friendly.
The trade-off emerges when you need frequent insertions or deletions at arbitrary positions. Vec::insert requires shifting all subsequent elements, which is O(n). For those workloads, you should reach for something else—but often, you don't actually need arbitrary insertion. Restructure your logic to append and process in batches instead.
HashMap with faster hashers like ahash
Hash maps are essential for lookups, but the default hasher in Rust's standard library prioritizes DOS-resistance over speed. In production systems where you control the input, you can trade that security margin for performance.
The ahash crate provides a faster hasher that's still cryptographically sound for most use cases. When you're building a cache layer or de-duplication system, the difference compounds:
use std::collections::HashMap;
// Standard library hasher (slow, DOS-resistant)
fn count_users_default(user_ids: &[u64]) -> HashMap {
let mut counts = HashMap::new();
for &id in user_ids {
*counts.entry(id).or_insert(0) += 1;
}
counts
}
// With ahash (faster for trusted input)
use ahash::AHashMap;
fn count_users_ahash(user_ids: &[u64]) -> AHashMap {
let mut counts = AHashMap::new();
for &id in user_ids {
*counts.entry(id).or_insert(0) += 1;
}
counts
}In benchmarks processing a million user IDs, AHashMap consistently runs 20–30% faster than the default hasher. That gap widens if your workload involves string keys or more complex hashing operations. For e-commerce inventory systems or CRM databases tracking customer interactions, this kind of per-operation savings multiplies across requests.
Start with the standard HashMap in new code. If profiling shows hash operations as a bottleneck, swap in AHashMap. Keep a comment explaining why—the next developer on your team should understand the trade-off.
SmallVec for small lists
Many data structures in real applications contain small lists—function call stacks, error context chains, or batch operations with typical sizes under 10 elements. For these cases, SmallVec from the smallvec crate avoids heap allocation entirely when the collection stays small.
use smallvec::{SmallVec, smallvec};
// Typical API error context
struct ApiError {
code: u16,
message: String,
context_stack: SmallVec<[String; 4]>,
}
fn build_error_context(operations: &[&str]) -> SmallVec<[String; 4]> {
let mut stack = SmallVec::new();
for op in operations.iter().take(4) {
stack.push(op.to_string());
}
stack
}
fn main() {
let ops = vec!["auth", "load_user", "fetch_permissions"];
let ctx = build_error_context(&ops);
let error = ApiError {
code: 401,
message: "Unauthorized".to_string(),
context_stack: ctx,
};
}SmallVec stores up to 4 strings inline in the ApiError struct. If error contexts stay small (they usually do), you never allocate. The memory footprint is exactly 4 * sizeof(String) plus the inline buffer. When you exceed the inline capacity, it spills to the heap automatically—no API changes needed.
This pattern is invaluable in systems handling request pipelines where you attach metadata to each request through multiple processing stages. In ride-hailing or delivery platforms, tracking which services processed a request can mean the difference between shipping on the stack or paying heap allocation tax for every request.
Measure before switching
Performance intuition is often wrong. Before redesigning your collection strategy, profile the actual code path. Use cargo flamegraph with perf to identify where time is actually spent.
// Benchmark example with criterion
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use std::collections::HashMap;
use ahash::AHashMap;
fn benchmark_hashmap(c: &mut Criterion) {
c.bench_function("HashMap insert", |b| {
b.iter(|| {
let mut map = HashMap::new();
for i in 0..10_000 {
map.insert(black_box(i), black_box(i * 2));
}
})
});
c.bench_function("AHashMap insert", |b| {
b.iter(|| {
let mut map = AHashMap::new();
for i in 0..10_000 {
map.insert(black_box(i), black_box(i * 2));
}
})
});
}
criterion_group!(benches, benchmark_hashmap);
criterion_main!(benches);Run benchmarks against realistic data sizes and distributions. A Vec might be faster for your specific query pattern than a HashMap, even though lookups are theoretically O(n) vs O(1). For 20 elements, a linear scan might be faster than hashing.
The common trap is premature optimization. If your profiling doesn't show collections as the bottleneck, leave them alone. Complex collection types add cognitive overhead and maintenance burden. Stick with boring, predictable choices until data proves otherwise.
Common mistakes
- Over-allocating Vec capacity: Calling
Vec::with_capacitywith a guess is worse than letting it grow naturally. You waste memory and hide the true allocation pattern. Use capacity hints only when you know the exact final size upfront. - Cloning between collections unnecessarily:
vec.into_iter().collect::<HashMap>()is fine;vec.iter().cloned().collect()when the vec is no longer needed is wasteful. Use ownership more carefully. - HashMap lookups in tight loops without caching: If you're doing
map.get(&key)thousands of times per operation, consider whether you should restructure to avoid repeated lookups. Cache the result or reorganize the algorithm. - Switching hashers without measuring:
ahashis usually faster, but your specific workload might not benefit. Always profile before and after changes. - Ignoring memory locality for large datasets: For multi-gigabyte datasets, the collection type matters less than access patterns. Iterating sequentially beats random access regardless of collection type. Design around memory layout, not collection names.
Key takeaways
Vecremains the default for good reason—contiguous memory and cache friendliness solve most problemsAHashMapprovides meaningful speed gains over the standard hasher when input is trusted; measure before adoptingSmallVeceliminates heap allocation for fixed-size small collections, valuable in request processing pipelines- Profile with realistic workloads and data sizes before redesigning collection usage
- Memory layout and access patterns matter more than collection type names—understand your bottleneck first
- Keep code boring and predictable until profiling directs you elsewhere






Comments
Loading comments…