Vectors

The collection type you will write more than any other in Rust: how Vec<T> grows on the heap, two ways to read from it with very different personalities, why pushing to one can make the borrow checker speak up, and the clean trick for storing more than one type inside it.

12 min read4 learning objectives

What You'll Learn

  • Create, grow, and read from a Vec<T> — and explain why every element has to share one type
  • Choose between direct indexing and .get() based on what an out-of-range index would actually mean
  • Iterate over a vector by shared and mutable reference, including how *i lets you modify elements in place
  • Recognize why pushing to a vector while holding a reference to one of its elements cannot compile — and explain the real bug that refusal prevents

Every type you've built over the last several lessons has held exactly one of something — one User, one Rectangle, one Message. Real programs are rarely about just one of anything: a list of users, a queue of pending jobs, a table of scores, a feed of messages. Time to meet the collection you'll reach for more than every other one combined — Vec<T>, Rust's growable list.

Creating and growing a vector

Vec::new() builds an empty vector — and because it starts with nothing inside to infer a type from, you have to spell one out: Vec<i32> reads as "a vector of i32s." .push() appends an element, growing the vector's heap allocation behind the scenes as needed — exactly the kind of memory management the rules from the ownership lesson exist to keep safe without you ever having to think about it. And the moment you already know what you want inside, the vec! macro skips the ceremony entirely.

One thing worth saying out loud: every element in a Vec<T> has to be the same type, T. That's not an arbitrary restriction — it's exactly what lets Rust lay every element out in memory at a predictable size and reach any of them instantly. (Hold that thought. There's a clean way around it, and it's waiting at the end of this lesson.)

Reading elements: two ways, with very different personalities

Two ways to reach into a vector, sitting right next to each other — and the difference between them is precisely the difference you spent all of last lesson learning to care about. Direct indexing reaches in and simply hands you a reference to whatever's there. v.get(2) hands you back an Option<&T>Some if the index is valid, None if it isn't. Recognize that shape? You spent an entire lesson learning exactly what to do with it.

What happens when the index is wrong

Here's where those two personalities genuinely part ways. Ask for the hundredth element of a five-element vector with direct indexing:

The program stops, right there, with a message that is at least honest about exactly what went wrong. Ask the same question with v.get(100) instead, and you simply get back None — no drama, no crash, just a value you already know precisely how to handle. So which do you reach for? Direct indexing, when an out-of-range index would mean something has gone seriously, unrecoverably wrong with your program's logic — a bug worth crashing loudly over, immediately. .get(), the moment "this index might reasonably be out of range" describes your actual situation — user input, a search result, anything arriving from outside your program's control.

Iterating over a vector

for i in &v walks every element by reference, leaving v completely intact and usable afterward — cleaner than indexing in a loop, and it sidesteps the whole bounds-checking question, since there's no index to ever get wrong. Want to change every element in place? Borrow mutably instead, and reach through the reference with *: *i += 50 reads as "take whatever i points to, and add 50 to it, in place." Drop the * and you'd be trying to add a number directly to a reference — and the compiler would, quite reasonably, have opinions about that.

A familiar shape returns: the borrow checker meets growth

Stare at this for a second before reading on. Does anything about it feel familiar from the borrowing lesson?

v.push(6) might need to grow the vector — and growing can mean asking the allocator for a brand-new, larger block of memory and copying every existing element into it, potentially leaving the old memory (and the spot first was quietly pointing at) behind entirely, invalid. Rust isn't being precious here: it just caught a real, honest-to-goodness bug — exactly the "looked fine yesterday, crashed in production this morning" memory bug that takes other languages down on a bad day — and refused to let your program run at all until you'd dealt with it. This is the borrow checker's entire reason for existing, showing up in genuinely practical code for the first time.

The clean way around "every element must be the same type"

Remember that thought we asked you to hold onto? Here's where it pays off. A vector needs exactly one type — but nothing says that type can't itself be an enum, and an enum is precisely how you represent "one of several different shapes of data" as a single type, the trick from the enums lesson. Wrap each real type in its own variant, and you've built a vector that — as far as the compiler is concerned — holds exactly one type, while in practice carrying integers, floats, and text side by side, each one perfectly itself, with match sorting them back out the instant you need it to:

Quick exercise

Write a function fn median_and_mode(numbers: &[i32]) -> (f64, i32) that returns both the median (the middle value once sorted — average the two middle values if the count is even) and the mode (the value that shows up most often) of a slice of integers. You'll want a fresh, sortable Vec for the median, and something that can count occurrences for the mode — which happens to be exactly where this course goes two lessons from now. If hash maps aren't in your toolbox yet, get the median working first, sketch the mode by hand, and circle back the moment you've met them properly.

Vec<T> is the collection you'll write more than any other, by a wide margin — and now you know not just how to build one, but how to read from it safely, change it in place, and even smuggle several types through it when the moment calls for it. Up next: the type that looks the simplest on the surface and somehow turns out to be the most subtle one in the entire language — strings, and exactly why Rust makes you think about them differently than almost anything else you've used before.