Vectors, Strings, and Hash Maps (Basics)
A beginner-friendly introduction to Rust's three most common collection types: vectors, strings, and hash maps. Covers creation, access, update, iteration, and the ownership interactions that make each safe and flexible.
Programs that only work with a single value at a time are rare. Almost every useful application needs to keep track of lists of items, groups of names, collections of measurements, or mappings from one piece of data to another. Rust’s standard library provides data structures for exactly these situations, and they are collectively called collections.
Unlike arrays and tuples, whose size is locked in when the program compiles, collections store their contents on the heap. That means the number of elements can grow and shrink as the program runs. This flexibility comes with responsibilities around ownership and memory, but Rust’s rules keep the whole process predictable and safe.
The three collections you will reach for most often are vectors, strings, and hash maps. This page introduces what each one does, how to create and work with them, and the patterns and pitfalls that matter day to day. The separate detailed pages later in this chapter take each collection further, but by the end of this overview you will be able to use all three in real programs.
Heap storage:
Arrays and tuples live on the stack and have a size known at compile time. Collections, because they can change size, allocate memory on the heap. The collection owns that memory and frees it when the collection goes out of scope—no garbage collector needed.
Vectors (Vec\u003cT\u003e)
A vector is a growable list that stores multiple values of the same type, one after the other in memory. In other languages you might call this a dynamic array, an ArrayList, or simply a list. In Rust the type is written Vec<T>, where T is the type of the elements it holds.
Creating a vector
The most common way to create a vector is with the vec! macro, which lets you list the initial elements. Rust infers the element type from the values you provide.
let numbers = vec![1, 2, 3, 4, 5];
let words = vec!["hello", "world"];
If you need an empty vector and plan to add elements later, use Vec::new() and provide a type annotation so the compiler knows what kind of vector to create.
let mut temperatures: Vec<f64> = Vec::new();
temperatures.push(36.5);
temperatures.push(37.0);
Both vec![] and Vec::new() + push produce the same result: a heap-allocated, growable list. When you know the initial items, the macro is shorter. When you are going to add elements one at a time, starting with Vec::new() is clearer.
Pre-allocating for performance:
If you know roughly how many elements a vector will hold, you can avoid repeated re-allocations with Vec::with_capacity(1000). The vector still grows beyond that capacity if needed, but the upfront allocation saves work during pushes. This is an optimization that rarely matters for small examples, but it is good to know it exists.
Accessing elements
There are two ways to get an element from a vector: direct indexing with [] and the .get() method. Their behaviour when the index is out of bounds is what separates them.
let colors = vec!["red", "green", "blue"];
// Direct indexing — panics if index doesn't exist
let first = colors[0]; // "red"
// Safe access with get() — returns an Option
let maybe_color = colors.get(2); // Some(&"blue")
let missing = colors.get(10); // None
Direct indexing uses the same bracket syntax as arrays, but if you accidentally ask for an index that is beyond the vector’s length, the program will panic and crash. The .get() method returns an Option<&T> — Some with a reference to the element if the index is valid, or None if it is not — forcing you to handle the missing case explicitly.
Indexing panics on out-of-bounds access:
A panic caused by vec[idx] with an invalid index is a runtime error that terminates the thread. In applications where crashing is unacceptable, prefer .get() and pattern-match on the Option. The compiler does not warn you about potential panics from indexing — that responsibility is yours.
Adding, removing, and modifying
Vectors support push to add an element to the end (constant amortized time) and pop to remove and return the last element.
let mut stack = vec![1, 2];
stack.push(3); // [1, 2, 3]
let last = stack.pop(); // Some(3), stack is now [1, 2]
Removing an element from the middle with .remove(index) shifts all later elements to the left, which takes time proportional to the number of shifted elements. If you need fast removals from both ends, the next chapter introduces VecDeque, but Vec is the right choice for most list-like workloads.
To modify an element in place, you can get a mutable reference using direct indexing (with bounds checking that panics) or with .get_mut(), which returns an Option.
let mut values = vec![10, 20, 30];
if let Some(elem) = values.get_mut(1) {
*elem = 25; // dereference to write the new value
}
Iterating over vectors
Iteration is how you visit every element. The three iteration methods — iter(), iter_mut(), and into_iter() — differ in how they treat ownership.
let items = vec![1, 2, 3];
// Borrow each element immutably — items still usable after
for value in items.iter() {
println!("{}", value);
}
let mut numbers = vec![1, 2, 3];
// Borrow each element mutably — allows modification in place
for value in numbers.iter_mut() {
*value *= 2; // numbers becomes [2, 4, 6]
}
// Take ownership and consume the vector — items no longer usable
for value in items {
println!("{}", value);
}
The first loop gives you a reference (&T). The second gives a mutable reference (&mut T). The third takes ownership of the vector and hands you each element by value; after that, the vector is gone. This is one of the places where Rust’s ownership system directly shapes the code: the iteration method you choose determines whether the collection is still alive afterwards.
Holding a reference while mutating:
Once you borrow an element from a vector — even immutably — you cannot call push or other methods that might reallocate the vector’s backing memory. Reallocation invalidates existing references, and the borrow checker prevents the program from compiling if you try. This is a common stumble when new learners try to iterate and push at the same time.
Storing different types with enums
Vectors are homogeneous: every element must have the exact same type. When you need a single vector to hold integers, floats, and text, wrap them in an enum.
enum Cell {
Integer(i32),
Float(f64),
Text(String),
}
let row = vec![
Cell::Integer(3),
Cell::Text(String::from("blue")),
Cell::Float(10.12),
];
// Match on the variant to use the inner value safely
for cell in &row {
match cell {
Cell::Integer(n) => println!("Integer: {}", n),
Cell::Float(f) => println!("Float: {:.2}", f),
Cell::Text(s) => println!("Text: {}", s),
}
}
This keeps type safety: the compiler ensures that you handle every possible variant when you use a match expression, so you never accidentally treat a float as an integer.
Strings
Rust’s String type is a growable, heap-allocated sequence of bytes that represents UTF-8 text. Beginners often confuse String (the owned, flexible collection) with &str (a string slice, a borrowed view into UTF-8 data). Think of String as the text you own and can change, and &str as a pointer to text that already lives somewhere — in a literal, a file, or a portion of a String.
Creating strings
The most direct way to create a String is from a string literal.
let greeting = String::from("Hello");
let mut message = "Hello".to_string();
Both lines produce the same owned String. The String::new() constructor gives you an empty string, and the format! macro builds a new string by interpolating values without taking ownership of them.
let empty = String::new();
let sentence = format!("{} has {} apples", "Alice", 3); // "Alice has 3 apples"
Growing and modifying
A String can be extended with push_str (append a string slice) and push (append a single character).
let mut s = String::from("foo");
s.push_str("bar"); // "foobar"
s.push('!'); // "foobar!"
Concatenation with the + operator works, but it takes ownership of the left-hand string.
let hello = String::from("Hello, ");
let world = String::from("world!");
let combined = hello + &world; // hello is moved, cannot be used anymore
format! is often clearer for combining multiple strings, because it borrows everything and creates a new String.
let hello = String::from("Hello");
let name = String::from("Aria");
let greeting = format!("{}, {}!", hello, name); // hello and name still usable
Indexing does not work (and why)
You cannot index into a Rust string with brackets. my_string[0] will not compile. The reason is that a String is a sequence of bytes representing UTF-8 code points, and some characters occupy more than one byte. Indexing by byte would not reliably give you a character; indexing by character would not be constant time. Rust refuses to let you write an operation that looks cheap but isn’t.
Instead, you iterate over characters or bytes explicitly.
let text = String::from("Rust 🦀");
// Iterate over Unicode scalar values (chars)
for c in text.chars() {
println!("{}", c);
}
// Iterate over raw bytes
for b in text.bytes() {
println!("{b}");
}
Slicing with ranges (e.g., &text[0..4]) works, but the range must fall on valid UTF-8 character boundaries. A slice that splits a multi-byte character in half will cause a panic at runtime.
String slicing can panic at runtime:
A slice like &text[0..1] will compile, but if byte 0 is the start of a multi-byte character, the program panics. Always validate boundaries or use methods that respect UTF-8, such as the unicode-segmentation crate for grapheme clusters.
Beginners coming from languages where strings are just arrays of characters often stumble on the absence of indexing and on the fact that len() returns the number of bytes, not the number of visible characters. A string containing "🦀" has a byte length of 4, but only one character.
Ownership and strings in collections
When you store a String inside a Vec or a HashMap, the collection takes ownership of it. If you only need the collection to read the text, storing &str references is possible but requires explicit lifetime annotations. For most examples early on, owned String values inside collections keep things simple.
Hash Maps (HashMap\u003cK, V\u003e)
A hash map stores key-value pairs and lets you look up a value by its key in constant average time. In other languages you might know this as a dictionary, an associative array, or a hash table. In Rust the type is HashMap<K, V>, and it lives in std::collections, so you need an explicit use statement.
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);
Creating and inserting
Unlike vectors, there is no built-in macro for hash maps. Besides new() and insert(), you can build a hash map from an iterator of key-value tuples using .collect().
use std::collections::HashMap;
let teams = vec![String::from("Blue"), String::from("Yellow")];
let initial_scores = vec![10, 50];
let scores: HashMap<_, _> =
teams.into_iter().zip(initial_scores.into_iter()).collect();
The type annotation HashMap<_, _> tells Rust that you want to .collect() into a HashMap; the underscores let it infer the exact key and value types from the iterator.
Accessing values
You retrieve a value with the get method, which returns an Option<&V>. That Option is the whole point: the key might not exist, and Rust makes you handle that possibility explicitly.
let team_name = String::from("Blue");
match scores.get(&team_name) {
Some(score) => println!("Blue team score: {}", score),
None => println!("Blue team is not playing"),
}
If you don’t care about the None case and just want a default, unwrap_or works well.
let score = scores.get("Green").unwrap_or(&0);
Ownership of keys and values
When you insert owned values like String, the hash map becomes the owner. The original variables are no longer usable.
let key = String::from("Red");
let value = 100;
let mut map = HashMap::new();
map.insert(key, value);
// key is no longer usable; value (an i32) is fine because i32 is Copy
Types that implement the Copy trait, like integers, are silently copied. For non-Copy types, the map takes ownership. If you need to keep the original key, either clone it or insert references — but then you must manage lifetimes.
Updating a hash map with the Entry API
The most common update pattern in hash maps is “insert a default if the key isn’t present, then modify the value.” Doing this with get and insert requires two lookups. The entry API does it with one.
let text = "hello world wonderful world";
let mut word_counts = HashMap::new();
for word in text.split_whitespace() {
let count = word_counts.entry(word).or_insert(0);
*count += 1;
}
// word_counts: {"hello": 1, "world": 2, "wonderful": 1}
entry returns an Entry enum that represents either an occupied slot or a vacant one. or_insert provides the default for the vacant case and returns a mutable reference to the value — either the one already there or the newly inserted one — so you can mutate it directly.
Entry API is the idiomatic way to update hash maps:
Using entry eliminates the risk of forgetting to handle the None case from get, reduces lookups, and reads like a plain description of the intent: “for this key, insert zero if it’s missing, then increment the value.”
Other update strategies — like always overwriting with insert, or only inserting if the key is absent — are straightforward. Overwriting is just another insert with the same key. Inserting only if absent is entry(key).or_insert(value) without a subsequent mutation.
Iteration order
Hash maps iterate over entries in an arbitrary order that can change between program runs. If you need keys in a specific sorted order, the standard library provides BTreeMap, covered later in this chapter.
for (key, value) in &scores {
println!("{}: {}", key, value);
}
Thinking about collections as a beginner
When you encounter a problem that involves a group of items, ask yourself three questions to pick the right collection:
- Do I care about order? Vectors preserve insertion order. Hash maps do not.
- Do I look up items by a unique key, not by position? Hash maps are built for that. Vectors require scanning.
- Is the group of items text? If the answer is yes and the text can change or grow,
Stringis the tool; if it is a fixed literal you only need to read, a&stris often enough.
A common early mistake is reaching for a vector when a hash map would be simpler — for example, storing a list of name–score pairs and then scanning the list every time you need a score. The mental model shift is to think about access patterns first, and only then about how to store the data.
With vectors, strings, and hash maps, you now have the three foundational containers that Rust programs build on. They share a few important qualities: they all live on the heap, they all can grow at runtime, and they all interact with Rust’s ownership system to prevent use-after-free bugs and data races. Vectors give you ordered lists, strings give you owned, safe text, and hash maps give you fast key-based lookups.