HashSet<T> and BTreeSet<T>
Understand Rust's set collections HashSet and BTreeSet - their differences, common operations, and when to use each.
A set is a collection that remembers which values you have seen, with the guarantee that each value appears at most once. If you insert the same value twice, the set ignores the second insertion. There is no "value" attached to each entry—only the key matters.
Rust offers two set types: HashSet<T> and BTreeSet<T>. They serve the same fundamental purpose but differ in how they organise elements internally, what they require from the type T, and the performance characteristics of their operations.
Sets are thin wrappers around Maps:
Under the hood, HashSet<T> is a HashMap<T, ()> and BTreeSet<T> is a BTreeMap<T, ()>. The value () takes no space, so each entry is just the key. Because of this, all map operations carry over to sets with the same complexity bounds. If you understand the corresponding map type, you already understand how the set works mechanically.
How the Two Set Types Differ
The fundamental difference lies in the contract used to determine uniqueness and order.
HashSet<T>requiresT: Eq + Hash. It places elements into buckets based on their hash and uses equality for collision resolution. Iteration order is arbitrary and may change between runs or even after insertions. Average-case lookup, insertion, and removal are O(1).BTreeSet<T>requiresT: Ord. It stores elements in a balanced tree (a B-tree) that keeps them sorted by theOrdimplementation. Iteration always visits elements in ascending order. All fundamental operations are O(log n).
This trade-off means the choice between them is rarely about "which is better" and more about what your data needs to support.
| Criterion | HashSet<T> | BTreeSet<T> |
|---|---|---|
| Required traits | Eq + Hash | Ord |
| Lookup / insert / remove | O(1) average, O(n) worst-case | O(log n) guaranteed |
| Iteration order | Arbitrary, unstable | Ascending by Ord |
| Range queries | Not supported | Efficient (range() method) |
| Memory overhead | Higher (hash table buckets) | Lower per element (tree nodes) |
| HashDoS resistance | Built-in (random seed per instance) | Not applicable |
Core Operations
The API surface of both sets is largely identical. The following examples show the common operations using both types side by side. Choose the tab that matches the set you intend to use.
use std::collections::HashSet;
fn main() {
// Creating an empty set
let mut numbers = HashSet::new();
// Inserting elements – returns true if the value was new
assert!(numbers.insert(42));
assert!(!numbers.insert(42)); // duplicate, ignored
// Checking membership
if numbers.contains(&42) {
println!("42 is in the set");
}
// Removing an element – returns true if it was present
let was_present = numbers.remove(&42);
assert!(was_present);
// Pre-allocating capacity when you know the size ahead of time
let mut pre_sized: HashSet<i32> = HashSet::with_capacity(1_000);
// Constructing from an array
let colors = HashSet::from(["red", "green", "blue"]);
}
The insert method returns a bool that tells you whether the value was actually new to the set. This is often useful when you need to track first-seen occurrences without separate lookups.
Set Iteration
Iterating over a set follows the same borrow rules as any collection, but there is an important restriction: neither HashSet nor BTreeSet provides an iter_mut() method. The reason is safety: mutating an element in place could change its hash or ordering, breaking the set’s internal invariants. You can only obtain immutable references (iter()) or take ownership of elements (into_iter()).
Immutable iteration
Both sets provide an iter() method that yields &T. The order, however, is where the two differ.
use std::collections::HashSet;
let numbers: HashSet<_> = [3, 1, 4, 1, 5].into_iter().collect();
// Output may appear in any order, for example: 5, 3, 1, 4
for n in numbers.iter() {
println!("{}", n);
}
With a BTreeSet, the same code will always print the numbers in ascending order: 1, 3, 4, 5.
Consuming iteration
When you are done with the set and want to move the values out, use into_iter(). After this, the set is consumed and can no longer be used.
use std::collections::BTreeSet;
let set: BTreeSet<_> = ["gamma", "alpha", "beta"].into_iter().collect();
let collected: Vec<_> = set.into_iter().collect();
assert_eq!(collected, vec!["alpha", "beta", "gamma"]);
No mutable iteration on sets:
There is no iter_mut() on HashSet or BTreeSet. If you need to modify elements while they are in the set, remove them, change them, and re-insert. Attempting to mutate through a reference obtained from get (which returns Option<&T>) won't compile because the borrow is shared and immutable. Avoid interior mutability (Cell, RefCell) on key fields unless you are certain the mutation does not affect equality or ordering.
When Equal Values Are Different
The idea that two values are "equal" depends on which trait the set uses. HashSet decides equality through the Eq trait and hashing through Hash. BTreeSet decides uniqueness through Ord, specifically when cmp returns Ordering::Equal.
For custom types, you must ensure these traits are consistent. The standard derive macros (#[derive(Eq, PartialEq, Hash, Ord, PartialOrd)]) guarantee this as long as all fields participate in the derivation uniformly. If you implement them manually, the following rule must hold for HashSet:
k1 == k2 → hash(k1) == hash(k2)
And for BTreeSet, the Ord implementation must form a total order consistent with Eq: a == b must imply a.cmp(&b) == Ordering::Equal.
Violating trait contracts causes logic errors:
If two values compare as equal by Eq but produce different hashes, HashSet will treat them as distinct elements, possibly storing both. If Ord says two values are not equal while Eq says they are, BTreeSet may contain both. The standard library documentation explicitly states that these are logic errors. The behaviour is not undefined, but it will produce incorrect results, panics, or memory leaks depending on the operation.
The following example derives all required traits consistently. This is the safe, recommended path for most types.
use std::collections::{HashSet, BTreeSet};
#[derive(Debug, Hash, Eq, PartialEq, Ord, PartialOrd)]
struct User {
id: u64,
name: String,
}
let mut hash_users = HashSet::new();
hash_users.insert(User { id: 1, name: "Alice".into() });
hash_users.insert(User { id: 1, name: "Alice".into() }); // ignored
let mut tree_users = BTreeSet::new();
tree_users.insert(User { id: 1, name: "Alice".into() });
tree_users.insert(User { id: 1, name: "Alice".into() }); // ignored
Because Eq and Ord are derived on all fields, a User is considered duplicate only if both fields match. If you wanted uniqueness based solely on id, you'd need to write manual trait implementations that compare and hash only the id field—and you'd have to do so carefully to keep Eq and Ord aligned.
Whole-Set Operations
Both HashSet and BTreeSet provide the standard mathematical set operations. Each operation returns a lazy iterator that borrows the original sets; you typically call .cloned() or .copied() and then .collect() to obtain a new owned set.
use std::collections::HashSet;
let a: HashSet<_> = [1, 2, 3, 4, 5].into_iter().collect();
let b: HashSet<_> = [3, 4, 5, 6, 7].into_iter().collect();
// Union: elements present in either set
let union: HashSet<_> = a.union(&b).copied().collect();
// {1, 2, 3, 4, 5, 6, 7} (order arbitrary)
// Intersection: elements present in both sets
let intersection: HashSet<_> = a.intersection(&b).copied().collect();
// {3, 4, 5}
// Difference: elements in `a` but not in `b`
let difference: HashSet<_> = a.difference(&b).copied().collect();
// {1, 2}
// Symmetric difference: elements in one but not both
let sym_diff: HashSet<_> = a.symmetric_difference(&b).copied().collect();
// {1, 2, 6, 7}
These methods exist identically on BTreeSet as well. With BTreeSet, the resulting iterators will produce values in sorted order, and the collected set will also be sorted.
The library also provides predicate-based checks that avoid building new sets:
assert!(a.is_disjoint(&b) == false); // no common elements?
assert!(a.is_subset(&b) == false); // is a ⊆ b ?
assert!(a.is_superset(&b) == false); // is a ⊇ b ?
The difference lifetime quirk:
When working with references as elements (e.g., HashSet<&str>), the difference method constrains the lifetime of the second argument to match the first argument's element lifetime. This is more restrictive than necessary because the output can only contain elements from the first set. If you encounter a compile error about lifetimes in such a scenario, consider writing a manual filter-based loop that only borrows the second set, or use a.iter().filter(|x| !b.contains(*x)). This issue is tracked in the Rust repository and may be relaxed in future editions.
Performance Considerations
The O(1) vs O(log n) trade-off is only part of the story. Here are a few practical implications:
- Iteration speed: Iterating a
HashSetvisits elements in essentially random memory order, which can be slower than iterating aBTreeSetdue to poor cache locality when the set is large. ABTreeSetiterates in-order, but pointer chasing between tree nodes still incurs overhead. For small sets,HashSetoften iterates faster because the elements sit in a contiguous buffer. - Memory overhead:
HashSetallocates a hash table with empty slots to keep the load factor low, which can consume more memory than the theoretical size of the elements.BTreeSetallocates nodes individually, with each node storing a few elements plus child pointers. The per-element overhead tends to be lower, but the allocation pattern is more fragmented. - Worst-case hashing: If an adversary can control the keys inserted into a
HashSetand the hasher is predictable, they can craft collisions that degrade performance to O(n). Rust's default hasher uses a random seed per instance to make this impractical.BTreeSetdoes not have this vulnerability.
Common Mistakes and Pitfalls
Assuming order in a HashSet:
Code that relies on a specific iteration order of a HashSet is broken by design. The order is not guaranteed across compilations, builds, or even between insertions. If you need sorted output, either use BTreeSet or sort a Vec collected from the set afterwards.
Using a type that changes its hash or ordering while stored:
Do not mutate a value (through Cell, RefCell, or unsafe code) in a way that alters its Hash, Eq, or Ord outcome while it is inside a set. This is a logic error and will cause the set to lose track of the element, potentially leaving it inaccessible for removal and breaking future lookups.
Pre-allocation is your friend:
If you are building a HashSet from a large iterator with a known size, use HashSet::with_capacity(size) to avoid repeated reallocations. For BTreeSet, there is no capacity hint, but the tree structure builds incrementally without large reallocation costs. Still, if you're collecting from an iterator that also supports size_hint, nothing prevents you from using collect() directly; the FromIterator implementation may optimise internally.
Forgetting that sets do not store duplicates:
Converting a Vec with duplicates to a HashSet or BTreeSet will silently drop the duplicates. If you need to preserve count information, consider using a map (e.g., HashMap<T, usize>) or keep the original Vec.
Summary
HashSet<T> and BTreeSet<T> are two views of the same abstract set concept, differentiated by the contract they impose on T and the performance profile they offer. Pick HashSet when you want fast average-case lookups and don't care about element order. Pick BTreeSet when you need sorted iteration, range queries, or consistent logarithmic behaviour regardless of key distribution. The operations—insertion, membership, removal, and whole-set algebra—are deliberately uniform across both types, so switching between them later usually requires only a type change and recompilation.