Recommended resources
The freely available resources this lesson will direct you to.
Welcome back. Last lesson, you chose between owned String and borrowed &str at function boundaries. Collections raise the same ownership question at a larger scale: when you put a value into a collection, who owns it, and what do you receive when you look it up?
This lesson covers Rust’s two everyday collections: Vec<T> for an ordered, growable sequence, and HashMap<K, V> for values found by key. You’ll store values, retrieve them without unnecessarily taking ownership, and handle lookups that might fail. These are useful building blocks for the text analyser at the end of this module and, later, for an in-memory API.
A vector owns a growable sequence
An array has a fixed length known as part of its type. A Vec<T> can grow while the program runs. Its elements are stored together in memory, and every element has the same type T.
Watch Engineer Man’s Let’s Learn Rust: Vectors and Hash Maps for the initial construction and push examples.
Let's Learn Rust: Vectors and Hash Maps
Engineer Man demonstrates two ways to create a vector and shows why appending requires a mutable binding.
Watch vector creation. Focus on the contrast between an empty Vec::new(), whose element type must be established by an annotation or later use, and vec!, whose initial values make the type apparent.
Here are both forms with text values:
let mut statuses: Vec<String> = Vec::new();
statuses.push(String::from("queued"));
statuses.push(String::from("sent"));
let initial_codes = vec![200, 201];
statuses is a growable sequence of String values. The type annotation makes its intended contents clear before the first insertion. For initial_codes, Rust infers a vector of integers from the initial values. The mut on statuses allows push to change the vector; it does not change what type of value the vector can hold.
Ownership still applies to each element. If you write:
let status = String::from("queued");
let mut statuses = Vec::new();
statuses.push(status);
// println!("{status}"); // Does not compile: the String moved into statuses.
the vector now owns that String. This is the collection version of passing a String to a function that takes ownership. By contrast, pushing an i32 copies that value, because i32 implements Copy.
Retrieving an element: known position or possible absence
Vector positions start at zero. Indexing is concise when a valid position is guaranteed:
let statuses = vec![
String::from("queued"),
String::from("sent"),
];
let first: &String = &statuses[0];
println!("{first}");
The & matters: first borrows the element rather than removing it from the vector. But &statuses[2] would panic at runtime, because this vector has only positions 0 and 1.
When an index might be invalid—perhaps it came from a command-line argument—use get:
let requested_index = 2;
match statuses.get(requested_index) {
Some(status) => println!("Found: {status}"),
None => println!("No status at position {requested_index}"),
}
get returns Option<&String> here: either Some containing a borrowed element or None when the index does not exist. You do not need to master enums and pattern matching yet; a later module treats them directly. For now, read this match as explicitly handling both possible lookup results. It does not move a String out of statuses.
The Rust book’s vector chapter explains this choice and connects it to borrowing rules you have already learned.
Storing Lists of Values with Vectors
Read the selected parts of the official Rust book chapter to understand why vector access borrows elements and why get is useful for uncertain indices.
In “Reading Elements of Vectors,” start at the paragraph beginning “There are two ways to reference a value stored in a vector.” Read the access comparison through the explanation following Listing 8-5. Then, still before “Iterating Over the Values in a Vector,” find the paragraph immediately after that discussion beginning “When the program has a valid reference.” Read the borrow explanation through the paragraph about possible reallocation. Focus on why keeping an element reference in use while calling push is prohibited.
That restriction is worth making concrete. A push might have to move the vector’s elements to a larger allocation. An old reference to an element could then point at invalid memory, so Rust rejects this ordering:
let mut numbers = vec![10, 20];
let first = &numbers[0];
numbers.push(30);
println!("{first}"); // Cannot use the earlier borrow after the push.
Use the reference before changing the vector, or obtain a fresh reference afterward. To read every element without taking ownership of the vector, borrow it in a loop:
for number in &numbers {
println!("{number}");
}
println!("There are {} numbers", numbers.len()); // Still own numbers.
The vector preserves element order. That makes it suitable when you care about the sequence in which records were added or want to retrieve an item by position.
A hash map finds values by key
A vector answers “what is at position 1?” A hash map answers “what value is associated with queued?” A HashMap<K, V> has one type for all keys and one type for all values. Unlike Vec, HashMap needs an import.
Return to the same Engineer Man video for its basic HashMap construction example.
Let's Learn Rust: Vectors and Hash Maps
This portion of Let’s Learn Rust: Vectors and Hash Maps introduces the import, typed keys and values, and insert.
Watch map insertion. Notice that inserting an existing key replaces its associated value. The video uses borrowed string keys; the example below instead gives the map owned String keys, which is often convenient when a collection needs to retain its data.
Suppose you want to retain a count for each status:
use std::collections::HashMap;
fn main() {
let mut counts: HashMap<String, usize> = HashMap::new();
counts.insert(String::from("queued"), 2);
counts.insert(String::from("sent"), 1);
match counts.get("queued") {
Some(count) => println!("Queued count: {count}"),
None => println!("No queued count recorded"),
}
}
The map owns the String keys inserted into it. Nevertheless, get("queued") can look up one of those keys using a borrowed string slice: it does not need to create and insert another String. Its result is Option<&usize>—a possibly absent borrow of the stored count. A missing key returns None, not a panic.
The book develops these retrieval and ownership points. Its examples use team scores rather than statuses, but the collection behaviour is the same.
Storing Keys with Associated Values in Hash Maps
Read the official Rust book’s treatment of get and of ownership when keys or values are inserted.
In “Accessing Values in a Hash Map,” begin with “We can get a value out of the hash map by providing its key.” Read the lookup discussion through the introduction to iteration. Then, in “Managing Ownership in Hash Maps,” begin with “For types that implement the Copy trait” and read the ownership contrast and its accompanying Listing 8-22. Focus on the difference between copying an integer value into a map and moving an owned String into it.
You can loop over borrowed key-value pairs:
for (status, count) in &counts {
println!("{status}: {count}");
}
Do not depend on the order of this output. A vector retains its sequence; a HashMap does not promise that iteration follows insertion order. When you need one particular count, look it up by key.
Inserting again, or updating what is already there
Calling insert with an existing key replaces its value:
counts.insert(String::from("queued"), 3);
There is still just one "queued" entry, now with count 3. But replacement is not always the desired operation. When counting observations, you want to keep the existing count and increase it—or start at zero if the key is new.
The entry API expresses that operation:
let count = counts.entry(String::from("queued")).or_insert(0);
*count += 1;
or_insert(0) inserts zero only if the key was missing. In either case, it returns a mutable reference to the count. The * accesses the integer through that reference so it can be incremented. You can think of entry as one lookup that gives you a place to update, without separately checking whether the key exists.
Read the book’s update examples with that distinction in mind:
Storing Keys with Associated Values in Hash Maps
Continue the Rust book chapter to distinguish replacing a value from inserting only when absent and updating an existing count.
Under “Updating a Hash Map,” read “Overwriting a Value” from its opening sentence, “If we insert a key and a value into a hash map,” through Listing 8-23 and the sentence explaining its result. Then read “Adding a Key and Value Only If a Key Isn’t Present”: locate the paragraph beginning “Hash maps have a special API for this called entry” and follow the entry explanation through the description of or_insert. Finally, under “Updating a Value Based on the Old Value,” read the word counter, including Listing 8-25. Focus on what the returned mutable reference points to.
Here is a complete small program combining the two collections. You can put it in a Cargo binary’s src/main.rs and run it with cargo run.
use std::collections::HashMap;
fn main() {
let mut statuses: Vec<String> = Vec::new();
statuses.push(String::from("queued"));
statuses.push(String::from("sent"));
statuses.push(String::from("queued"));
let mut counts: HashMap<String, usize> = HashMap::new();
for status in &statuses {
let count = counts.entry(status.clone()).or_insert(0);
*count += 1;
}
for status in &statuses {
println!("Observed: {status}");
}
match counts.get("queued") {
Some(count) => println!("Queued observations: {count}"),
None => println!("No queued observations"),
}
match statuses.get(3) {
Some(status) => println!("Fourth status: {status}"),
None => println!("There is no fourth status"),
}
}
The vector owns the status strings in their original order. The map also needs to own its keys, so this example clones each borrowed status when offering it as a key. That makes the ownership arrangement explicit, though it may do unnecessary cloning when a key already exists; optimising that is not needed to understand collection use. Neither get call takes ownership of a stored value, and both handle possible absence. The program reports two queued observations and no fourth status.
Conclusion
Use Vec<T> for a growable, ordered collection of values of one type; use HashMap<K, V> to associate typed keys with typed values. Inserting an owned String generally moves it into the collection. Reading with get instead borrows a stored value and returns Some or None; vector indexing is appropriate only when the position is known to exist. For maps, insert replaces an existing value, while entry(...).or_insert(...) helps update a value whether its key is new or already present.
Next, you’ll use borrowed strings and these collections together in a command-line text analyser.