Skip to content

Vec and String

Vec<T> is Rust’s workhorse sequential collection. It stores elements of type T contiguously on the heap and automatically resizes when you push beyond its current capacity.

let mut v: Vec<i32> = Vec::new(); // empty Vec
let w = vec![1, 2, 3]; // vec! macro shorthand
let mut v = vec![10, 20, 30];
v.push(40); // append an element
let last = v.pop(); // remove and return the last element → Some(40)
let first = v[0]; // index — panics if out of bounds
let safe = v.get(100); // returns Option<&T> — None if out of bounds

Prefer .get(i) when the index might be out of range so you handle the None case instead of panicking.

A Vec<T> tracks two numbers separately: len() (how many elements it currently holds) and capacity() (how many elements it can hold before it must allocate a bigger buffer). Vec::with_capacity(n) pre-allocates room for n elements up front — useful when you know roughly how big the Vec will get and want to avoid repeated reallocation.

flowchart LR
  A[push exceeds capacity] --> B[allocate a new, larger buffer]
  B --> C[copy every existing element into it]
  C --> D[free the old buffer]
  D --> E[push the new element]
What happens when a Vec outgrows its capacity
fn main() {
let mut v: Vec<i32> = Vec::with_capacity(2);
println!("start: len={} cap={}", v.len(), v.capacity());
for i in 0..6 {
v.push(i);
println!("after push {}: len={} cap={}", i, v.len(), v.capacity());
}
v.shrink_to_fit();
println!("after shrink_to_fit: len={} cap={}", v.len(), v.capacity());
}

This is a real, verified run — not the documented API contract (the standard library does not promise a specific growth factor), but the actual behavior of this toolchain:

start: len=0 cap=2
after push 0: len=1 cap=2
after push 1: len=2 cap=2
after push 2: len=3 cap=4
after push 3: len=4 cap=4
after push 4: len=5 cap=8
after push 5: len=6 cap=8
after shrink_to_fit: len=6 cap=6

Capacity doubles each time len() would exceed it — 2 → 4 → 8 — so pushing one element past capacity is amortized O(1): most pushes are free, and the occasional reallocation-and-copy is spread out over all the pushes that led up to it. shrink_to_fit() releases the unused slack (cap 8 → 6) once you know the Vec will not grow again.

Iterating a Vec: by Value, by Reference, by Mutable Reference

Section titled “Iterating a Vec: by Value, by Reference, by Mutable Reference”

The same loop body reads very differently depending on what you iterate over:

fn main() {
let v = vec![String::from("a"), String::from("b"), String::from("c")];
// by shared reference (&v): borrows, v still valid after
for s in &v {
print!("{} ", s);
}
println!("| still valid, len={}", v.len());
// by mutable reference (&mut v): borrows mutably, mutate elements in place
let mut nums = vec![1, 2, 3];
for n in &mut nums {
*n *= 10;
}
println!("{:?}", nums);
// by value (v): moves the Vec, v cannot be used after this loop
for s in v {
print!("{} ", s);
}
println!("(v moved)");
}
a b c | still valid, len=3
[10, 20, 30]
a b c (v moved)

for s in &v borrows each element as &T; the Vec is untouched and usable afterward. for n in &mut v borrows each element as &mut T, so *n *= 10 mutates the original Vec in place. for s in v calls v.into_iter() implicitly, which takes ownership of the Vec and yields owned T values — after this loop, v no longer exists. Reach for &v by default; use &mut v only when you need to mutate elements in place; use v only when you are done with the Vec and want its elements moved out.

String is Rust’s heap-allocated, mutable text type. It owns its bytes and manages deallocation.

let mut s = String::from("hello");
s.push(' '); // push a single char
s.push_str("world"); // append a string slice
let combined = s + " again"; // + moves s and appends
String&str
OwnershipOwnedBorrowed slice
LocationHeapStack / static / inside a String
MutableYes (with mut)No
Typical useBuilding/modifying textReading text

A &String automatically coerces to &str, so prefer &str in function parameters for maximum flexibility.

fn shout(s: &str) -> String {
s.to_uppercase()
}
let owned = String::from("hello");
shout(&owned); // &String coerces to &str
shout("world"); // string literal is already &str

The Same Smell, One Type Over: Vec<String> vs &[String] / &[&str]

Section titled “The Same Smell, One Type Over: Vec<String> vs &[String] / &[&str]”

The String-vs-&str decision above has a direct sibling one layer up the type: a function parameter of Vec<String> forces every caller to hand over ownership (or .clone() the whole Vec) even when the function only reads the elements. &[String] (or &[&str] if callers already have string slices) borrows instead, and still accepts &some_vec at the call site because Vec<String> derefs to &[String]:

fn print_lines_bad(lines: Vec<String>) {
for line in &lines {
println!("{}", line);
}
}
fn print_lines_good(lines: &[String]) {
for line in lines {
println!("{}", line);
}
}
fn main() {
let owned = vec![String::from("a"), String::from("b")];
print_lines_good(&owned);
println!("owned still usable: {:?}", owned);
print_lines_bad(owned); // must move — cannot reuse afterward
}

String is UTF-8, so .len() returns the number of bytes, not the number of characters. For ASCII text the two happen to match, which hides the difference until you meet multi-byte text — every Thai character below is 3 bytes:

fn main() {
let s = "สวัสดี";
println!("len() = {}", s.len()); // bytes
println!("chars().count() = {}", s.chars().count()); // characters
}

This prints len() = 18 and chars().count() = 6 — 6 characters, 18 bytes.

Byte-range slicing (&s[start..end]) indexes into that byte buffer directly. If start or end does not land on a character boundary, Rust panics rather than hand you a half-character:

// @expect-panic
fn main() {
let s = "สวัสดี";
let bad = &s[..1]; // byte index 1 lands inside the first character
println!("{}", bad);
}
thread 'main' (<id>) panicked at src/main.rs:4:17:
end byte index 1 is not a char boundary; it is inside 'ส' (bytes 0..3 of string)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Reach for one of these instead of guessing at byte offsets:

fn main() {
let s = "สวัสดี";
// char_indices() gives you (byte_offset, char) pairs — always on a boundary
if let Some((offset, ch)) = s.char_indices().next() {
println!("first char '{}' starts at byte {}", ch, offset);
}
// chars().nth(n) gets the nth character without slicing bytes
println!("2nd char = {:?}", s.chars().nth(1));
// is_char_boundary(i) lets you check before you slice
println!("is_char_boundary(1) = {}", s.is_char_boundary(1));
println!("is_char_boundary(3) = {}", s.is_char_boundary(3));
}

An index loop (for i in 0..v.len() { ... v[i] ... }) is what most languages without a first-class iterator protocol teach you to reach for. In Rust it is almost always the wrong default: it re-checks bounds on every v[i], obscures the intent (“walk every element” becomes “count from 0 to len, then index”), and clippy calls it out by name:

fn main() {
let v = vec![10, 20, 30];
let mut total = 0;
for i in 0..v.len() {
total += v[i];
}
println!("{}", total);
}
$ cargo clippy
warning: the loop variable `i` is only used to index `v`
--> src/main.rs:4:14
|
4 | for i in 0..v.len() {
| ^^^^^^^^^^
|
= help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.96.0/index.html#needless_range_loop
= note: `#[warn(clippy::needless_range_loop)]` on by default
help: consider using an iterator
|
4 - for i in 0..v.len() {
4 + for <item> in &v {
|

clippy::needless_range_loop fires whenever the loop variable is used only to index the same collection — the fix is always for item in &v (or .iter().sum() here specifically), which the compiler can bounds-check once instead of once per access, and which reads as “for every item” instead of “for every index into this specific Vec”.

fn main() {
// Vec<i32> basics
let mut nums: Vec<i32> = Vec::new();
nums.push(10);
nums.push(20);
nums.push(30);
println!("len={}, first={}", nums.len(), nums[0]);
// pop returns Option<T>
let last = nums.pop();
println!("popped: {:?}, remaining: {:?}", last, nums);
// safe get returns Option<&T>
println!("get(5) = {:?}", nums.get(5));
// iterate by reference — nums still valid after
for n in &nums {
println!("item: {}", n);
}
println!("nums still valid: {:?}", nums);
// String vs &str
let literal: &str = "hello";
let mut owned: String = String::from(literal);
owned.push_str(" world");
println!("{}", owned);
// slice into a String
let slice: &str = &owned[0..5];
println!("slice: {}", slice);
}

A Vec<T> is three words on the stack — a pointer, a length, and a capacity — pointing at a single contiguous buffer on the heap. len is how much of that buffer is “live”; capacity is how much is actually allocated. Every push either writes into already-allocated slack (cheap) or triggers “allocate bigger, copy everything, free the old buffer” (expensive, but rare because capacity doubles). A String is the exact same three-word shape, specialized to hold UTF-8 bytes instead of an arbitrary T — which is why .len() counts bytes: it is reading the same field a Vec<u8> would report.

Vec::with_capacity(n) trades an up-front allocation for avoiding N-ish reallocations later — worth it whenever you know (or can estimate) the final size before the loop that fills it. shrink_to_fit() trades a possible reallocation now for a smaller long-term memory footprint — worth it for a Vec you are about to store for a long time, not for one you are about to grow again. Byte indexing into a String (&s[a..b]) is the fastest possible slice operation (no scanning), but it pushes the “did I land on a character boundary” responsibility entirely onto you; chars()/char_indices() do that scanning for you at the cost of walking the string.

  • “Vec::new() and Vec::with_capacity(0) behave differently.” They don’t — both start with zero capacity and allocate on the first push; with_capacity only matters when you pass a number greater than zero.
  • “.len() on a String counts characters, since Rust strings are Unicode-aware.” It counts bytes. Rust strings are UTF-8-aware, which is precisely why .len() must report bytes — a single “character” position doesn’t correspond to a fixed byte width.
  • “An index loop (for i in 0..v.len()) is more explicit and therefore safer than for x in &v.” It is strictly less safe in practice: it is the only style capable of causing a bounds panic from a typo (v[i + 1]), and clippy flags it as unnecessary precisely because for x in &v bounds-checks nothing because it never computes an out-of-range index in the first place.

AI-generated code defaults to index loops constantly, likely because they are the most common pattern across all the non-Rust code in its training data — but in Rust they are both slower (redundant bounds checks) and a clippy warning waiting to fire.

🐛 Spot the bug

fn sum_all(values: &[i32]) -> i32 {
let mut total = 0;
for i in 0..values.len() {
total += values[i];
}
total
}
Show the answer

i exists only to compute values[i] — the loop never uses the index for anything else. This is exactly the shape clippy::needless_range_loop warns about: total += values[i] re-derives a bounds-checked access on every iteration for no benefit. The fix is for v in values { total += v; }, or simply values.iter().sum() — both let the iterator handle traversal and the compiler generate tighter code.

What does Vec::pop() return when the Vec is empty?
What is the difference between iterating with &nums versus nums?
Why should function parameters prefer &str over &String?
Which method should you use instead of v[i] when the index might be out of bounds?
What do these two runs print, in order?
fn main() {
    let mut v: Vec<i32> = Vec::with_capacity(2);
    for i in 0..3 {
        v.push(i);
        println!("len={} cap={}", v.len(), v.capacity());
    }
}
Why does clippy::needless_range_loop flag for i in 0..v.len() { total += v[i]; }?