What are the trade-offs between indexmap::IndexSet and std::collections::HashSet for ordered unique collections?
IndexSet preserves insertion order while HashSet does not—this fundamental difference affects iteration behavior, lookup patterns, and memory layout. HashSet is optimized for fast membership testing with O(1) average lookup, using a hash table where element positions depend on hash values. IndexSet maintains a separate index structure that remembers insertion order, enabling predictable iteration and index-based access at the cost of additional memory overhead. The choice depends on whether you need ordered iteration, positional access, or maximum lookup performance.
HashSet: Unordered but Fast
use std::collections::HashSet;
fn main() {
let mut set = HashSet::new();
set.insert("first");
set.insert("second");
set.insert("third");
// Iteration order is NOT guaranteed
// May print in any order: "second", "first", "third"
for item in &set {
println!("{}", item);
}
// Fast membership testing - O(1) average
assert!(set.contains("first"));
assert!(!set.contains("fourth"));
// No index-based access
// Cannot do: set[0] or set.get_index(0)
}HashSet prioritizes lookup speed over iteration order.
IndexSet: Ordered with Predictable Iteration
use indexmap::IndexSet;
fn main() {
let mut set = IndexSet::new();
set.insert("first");
set.insert("second");
set.insert("third");
// Iteration order is GUARANTEED to match insertion order
// Always prints: "first", "second", "third"
for item in &set {
println!("{}", item);
}
// Index-based access - O(1)
assert_eq!(set.get_index(0), Some(&"first"));
assert_eq!(set.get_index(1), Some(&"second"));
// Still O(1) membership testing
assert!(set.contains("first"));
}IndexSet maintains insertion order and provides positional access.
Performance Characteristics
use std::collections::HashSet;
use indexmap::IndexSet;
fn main() {
// HashSet memory layout:
// - Single hash table
// - Elements scattered based on hash
// - Lower memory overhead
// IndexSet memory layout:
// - Hash table for lookups
// - Separate Vec for ordering
// - Higher memory overhead (~2x)
// HashSet operations:
// - insert: O(1) average
// - contains: O(1) average
// - remove: O(1) average
// - iteration: O(n) but order undefined
// IndexSet operations:
// - insert: O(1) average
// - contains: O(1) average
// - remove: O(1) average (swap_remove) or O(n) (shift_remove)
// - iteration: O(n) with guaranteed order
// - get_index: O(1)
// - get_index_of: O(1)
println!("Both provide O(1) membership testing");
println!("IndexSet adds O(1) index access");
println!("IndexSet uses more memory for ordering");
}Both have O(1) lookups; IndexSet adds ordering overhead.
Index-Based Operations
use indexmap::IndexSet;
fn main() {
let mut set = IndexSet::new();
set.insert("apple");
set.insert("banana");
set.insert("cherry");
// Get by index
let first = set.get_index(0);
assert_eq!(first, Some(&"apple"));
// Get index of value
let idx = set.get_index_of(&"banana");
assert_eq!(idx, Some(1));
// Remove by index (swap with last, then pop)
let removed = set.swap_remove_index(0);
assert_eq!(removed, Some("apple".to_string()));
// After swap_remove, order changes:
// "banana" might now be at index 0
println!("After removal: {:?}", set.iter().collect::<Vec<_>>());
// Insert at specific position
let mut set2 = IndexSet::new();
set2.insert("a");
set2.insert("c");
set2.insert_at(1, "b"); // Insert "b" at index 1
// Now: ["a", "b", "c"]
assert_eq!(set2.get_index(0), Some(&"a"));
assert_eq!(set2.get_index(1), Some(&"b"));
assert_eq!(set2.get_index(2), Some(&"c"));
}IndexSet provides get_index, get_index_of, and insert_at for positional operations.
Removal Strategies
use indexmap::IndexSet;
fn main() {
let mut set = IndexSet::new();
set.insert("a");
set.insert("b");
set.insert("c");
set.insert("d");
// swap_remove: O(1), but changes order
// Swaps element with last, then removes last
set.swap_remove("b");
// Order might now be: ["a", "d", "c"]
let mut set2 = IndexSet::new();
set2.insert("a");
set2.insert("b");
set2.insert("c");
set2.insert("d");
// shift_remove: O(n), preserves order
// Shifts all subsequent elements
set2.shift_remove("b");
// Order preserved: ["a", "c", "d"]
// HashSet remove: O(1), no order to preserve
let mut hash_set = std::collections::HashSet::new();
hash_set.insert("a");
hash_set.insert("b");
hash_set.remove("b"); // No order implications
}IndexSet offers swap_remove (fast, changes order) or shift_remove (slower, preserves order).
Use Case: Ordered Display
use indexmap::IndexSet;
fn main() {
// When user-facing display order matters
let mut menu_items = IndexSet::new();
// Items appear in insertion order
menu_items.insert("File");
menu_items.insert("Edit");
menu_items.insert("View");
menu_items.insert("Help");
// Menu displays predictably
println!("Menu:");
for (i, item) in menu_items.iter().enumerate() {
println!(" {}. {}", i + 1, item);
}
// Output:
// Menu:
// 1. File
// 2. Edit
// 3. View
// 4. Help
// HashSet would display in unpredictable order
}Use IndexSet when display order matters.
Use Case: Ordered Command Processing
use indexmap::IndexSet;
fn main() {
// Process commands in order they were added
// But avoid duplicates
let mut commands = IndexSet::new();
commands.insert("init");
commands.insert("load");
commands.insert("validate"); // First occurrence
commands.insert("validate"); // Ignored (duplicate)
commands.insert("save");
// Execute in order
for cmd in &commands {
println!("Executing: {}", cmd);
}
// Output:
// Executing: init
// Executing: load
// Executing: validate
// Executing: save
}IndexSet ensures unique commands while preserving execution order.
Use Case: Fast Position Lookup
use indexmap::IndexSet;
fn main() {
let mut users = IndexSet::new();
users.insert("alice");
users.insert("bob");
users.insert("charlie");
// Find position quickly - O(1)
if let Some(pos) = users.get_index_of(&"bob") {
println!("bob is at position {}", pos);
}
// This is useful when:
// - Building sparse arrays
// - Tracking order of appearance
// - Implementing ranked leaderboards
// In a leaderboard, you can quickly find someone's rank
let leaderboard: IndexSet<&str> = ["player1", "player2", "player3"]
.iter().copied().collect();
let rank = leaderboard.get_index_of(&"player2").map(|i| i + 1);
println!("player2 rank: {:?}", rank); // Some(2)
}get_index_of provides O(1) position lookup, useful for ranking systems.
Memory Comparison
use std::collections::HashSet;
use indexmap::IndexSet;
fn main() {
// HashSet memory:
// - Hash table with buckets
// - Each element stored once
// - Load factor affects memory
// IndexSet memory:
// - Hash table (similar to HashSet)
// - Additional Vec<usize> for indices
// - Roughly 1.5-2x memory of HashSet
let elements: Vec<String> = (0..1000)
.map(|i| format!("element_{}", i))
.collect();
let hash_set: HashSet<String> = elements.iter().cloned().collect();
let index_set: IndexSet<String> = elements.iter().cloned().collect();
// Approximate size comparison
println!("HashSet size hint: {} bytes", std::mem::size_of_val(&hash_set));
println!("IndexSet size hint: {} bytes", std::mem::size_of_val(&index_set));
// For large sets, IndexSet has noticeable memory overhead
// But provides:
// - Ordered iteration
// - Index access
// - Position lookup
}IndexSet uses more memory for its ordering guarantees.
Conversion Between Types
use std::collections::HashSet;
use indexmap::IndexSet;
fn main() {
// HashSet to IndexSet
let hash_set: HashSet<&str> = ["a", "b", "c"].iter().copied().collect();
let index_set: IndexSet<&str> = hash_set.into_iter().collect();
// Note: Order is still arbitrary (depends on hash)
// IndexSet preserves insertion order, but hash_set had arbitrary order
// IndexSet to HashSet
let index_set: IndexSet<&str> = ["x", "y", "z"].iter().copied().collect();
let hash_set: HashSet<&str> = index_set.into_iter().collect();
// Order is lost when converting to HashSet
// Maintaining order with ordered source:
let ordered: IndexSet<&str> = vec!["first", "second", "third"]
.into_iter().collect();
// Order preserved from Vec:
for (i, item) in ordered.iter().enumerate() {
println!("{}: {}", i, item);
}
}Order is lost when converting to HashSet.
Serde Serialization Behavior
use indexmap::IndexSet;
use serde::{Serialize, Deserialize};
// IndexSet serializes as a sequence, preserving order
// HashSet serializes as a sequence, but order is undefined
fn main() {
let mut set = IndexSet::new();
set.insert("first");
set.insert("second");
set.insert("third");
// Serializes to: ["first", "second", "third"]
// Deserialization preserves order
// This is useful for:
// - JSON arrays where order matters
// - Configuration files with ordered lists
// - API responses with predictable serialization
}IndexSet serialization preserves order, critical for deterministic JSON output.
Converting to Vec
use indexmap::IndexSet;
fn main() {
let mut set = IndexSet::new();
set.insert("a");
set.insert("b");
set.insert("c");
// Convert to Vec preserving order
let vec: Vec<&str> = set.into_iter().collect();
assert_eq!(vec, vec!["a", "b", "c"]);
// Or keep the IndexSet and iterate
let mut set2 = IndexSet::new();
set2.insert("x");
set2.insert("y");
set2.insert("z");
let vec2: Vec<_> = set2.iter().collect();
assert_eq!(vec2, vec![&"x", &"y", &"z"]);
}IndexSet::into_iter() produces elements in insertion order.
When to Use Each
use std::collections::HashSet;
use indexmap::IndexSet;
fn main() {
// Use HashSet when:
// - Only need membership testing
// - Don't care about iteration order
// - Memory efficiency matters
// - No need for positional access
// - Removing elements frequently (no order preservation)
let mut hash_set = HashSet::new();
hash_set.insert("key1");
hash_set.insert("key2");
if hash_set.contains("key1") {
println!("Found in hash_set");
}
// Use IndexSet when:
// - Need ordered iteration
// - Want to access elements by position
// - Need to find position of elements
// - Serialization order matters
// - Implementing ordered unique lists
let mut index_set = IndexSet::new();
index_set.insert("key1");
index_set.insert("key2");
if let Some(pos) = index_set.get_index_of(&"key1") {
println!("key1 is at position {}", pos);
}
// Deterministic iteration
for item in &index_set {
println!("{}", item);
}
}Choose HashSet for pure membership testing; IndexSet for order matters.
API Comparison
use std::collections::HashSet;
use indexmap::IndexSet;
fn main() {
// Common operations (both have):
// - insert(&T) -> bool
// - contains(&T) -> bool
// - remove(&T) -> bool
// - len() -> usize
// - is_empty() -> bool
// - iter() -> Iterator
// - clear()
// IndexSet-only operations:
// - get_index(usize) -> Option<&T>
// - get_index_of(&T) -> Option<usize>
// - get_index_mut(usize) -> Option<&mut T> (if T: Clone)
// - swap_remove_index(usize) -> Option<T>
// - shift_remove_index(usize) -> Option<T>
// - insert_at(usize, T)
// - move_index(usize, usize)
// - swap_indices(usize, usize)
// HashSet has no positional operations
let mut index_set = IndexSet::new();
index_set.insert("a");
index_set.insert("b");
index_set.insert("c");
// Move "b" to end
index_set.move_index(1, 2);
// Now: ["a", "c", "b"]
// Swap first and last
index_set.swap_indices(0, 2);
// Now: ["b", "c", "a"]
}IndexSet adds many positional operations not available in HashSet.
Real-World Example: Ordered Configuration
use indexmap::IndexSet;
// Configuration where order matters
struct Config {
search_paths: IndexSet<String>,
}
impl Config {
fn new() -> Self {
Config {
search_paths: IndexSet::new(),
}
}
fn add_path(&mut self, path: &str) {
// Duplicates are ignored, order preserved
self.search_paths.insert(path.to_string());
}
fn search(&self, filename: &str) -> Option<String> {
// Search in order - first match wins
for path in &self.search_paths {
let full_path = format!("{}/{}", path, filename);
if std::path::Path::new(&full_path).exists() {
return Some(full_path);
}
}
None
}
fn get_priority(&self, path: &str) -> Option<usize> {
// Lower index = higher priority
self.search_paths.get_index_of(path)
}
}
fn main() {
let mut config = Config::new();
config.add_path("/usr/local");
config.add_path("/usr");
config.add_path("/home/user");
config.add_path("/usr/local"); // Ignored, already exists
println!("Search order:");
for (i, path) in config.search_paths.iter().enumerate() {
println!(" {}: {}", i, path);
}
// Priority: lower index = searched first
if let Some(pos) = config.get_priority("/usr") {
println!("Priority of /usr: {}", pos);
}
}IndexSet ensures search order is deterministic and configurable.
Synthesis
Quick reference:
use std::collections::HashSet;
use indexmap::IndexSet;
fn main() {
// HashSet: Unordered, minimal memory
let mut hs: HashSet<&str> = HashSet::new();
hs.insert("a");
hs.insert("b");
// Order undefined
// IndexSet: Ordered, more memory
let mut is: IndexSet<&str> = IndexSet::new();
is.insert("a");
is.insert("b");
// Order preserved: ["a", "b"]
// Common: O(1) membership testing
assert!(hs.contains("a"));
assert!(is.contains("a"));
// IndexSet-only: O(1) index access
assert_eq!(is.get_index(0), Some(&"a"));
assert_eq!(is.get_index_of(&"b"), Some(1));
// Memory: IndexSet uses ~2x memory of HashSet
// Choose HashSet when:
// - Only membership testing needed
// - Memory constrained
// - Order doesn't matter
// Choose IndexSet when:
// - Need ordered iteration
// - Need positional access
// - Need to find element positions
// - Serialization order matters
// - Implementing ordered unique lists
}Key insight: The trade-off is clear—HashSet for maximum performance and minimal memory when order doesn't matter; IndexSet when you need ordered iteration, positional access, or deterministic serialization. Both provide O(1) membership testing, but IndexSet maintains a separate index structure that enables get_index(), get_index_of(), and guaranteed iteration order. For removal, swap_remove is O(1) but changes order, while shift_remove preserves order at O(n) cost. Use IndexSet for configuration paths, command queues, ordered menus, or any scenario where both uniqueness and order matter. Use HashSet for pure set operations like deduplication without ordering concerns.
