The Slice Type

A slice lets you refer to a contiguous run of elements inside a collection — part of a String, part of an array, part of a Vec — without copying that data and without taking ownership of it. Slices are what make Rust’s borrowing rules practical for everyday code: instead of duplicating a substring or a sub-array every time you want to work with a piece of a collection, you borrow a view into it, and the compiler guarantees that view stays valid. Once slices click, a lot of idiomatic Rust starts making sense — why so many functions take &str instead of &String, and why &[i32] shows up everywhere instead of &Vec<i32>.

Overview: What a Slice Actually Is

A slice is a reference to a range of elements in a collection, stored as two pieces of information: a pointer to the first element in the range, and a length. It does not own the data it points to — it borrows it, just like &i32 borrows an i32. The difference is that a plain reference like &i32 points at exactly one value, while a slice reference points at a run of values sitting next to each other in memory. Because a slice never owns its data, creating one is cheap: no heap allocation, no copying, just a pointer and a length bundled together.

Why Not Just Use an Index?

Imagine writing a function that finds the first word of a sentence and returns where it ends, using a plain usize index. That works until the caller mutates the string afterward — say, by calling .clear() on it. The index you returned is now meaningless: it might point past the end of the string, or into the middle of a different word entirely, and nothing in the type system stops you from using it. This exact class of bug (a stale offset into data that has since changed shape) is a classic source of memory-safety issues in languages without a borrow checker.

Rust closes this hole with slices. A slice is not a bare number; it is a reference, with a lifetime the borrow checker tracks. If a function returns a slice borrowed from a String, the borrow checker will not let you mutate that String (for example by clearing it) while the slice is still alive. The invalid-index problem becomes a compile error instead of a runtime surprise.

Fat Pointers: How a Slice Reference Is Stored

An ordinary reference like &i32 is a single machine word: just an address. A reference to a slice, whether it is &str or &[i32], is sometimes called a fat pointer because it carries two words: the address of the first element, and the number of elements in the range. This is why you can pass a slice around cheaply (it is just those two words being copied), and why a slice always knows its own length — you never need to pass a separate length argument alongside it, unlike raw arrays in C.

String slices deserve a special note: &str is specifically a slice of bytes that are guaranteed to form valid UTF-8. When you write a string literal like let s = "hello";, the type of s is &'static str — a slice pointing directly into the read-only data baked into your compiled binary. String itself is really a wrapper around a growable buffer of bytes plus the same UTF-8 guarantee; when you borrow from a String, you typically get back a &str slice into its buffer.

Syntax

You create a slice with range syntax inside square brackets, applied to something that already supports indexing (a String, a &str, an array, or a Vec<T>).

&collection[start..end]
&collection[start..=end]
&collection[start..]
&collection[..end]
&collection[..]
Form Meaning
&s[start..end] Elements from index start up to, but not including, end
&s[start..=end] Elements from start through end, inclusive
&s[start..] From start to the end of the collection
&s[..end] From the beginning up to (not including) end
&s[..] The entire collection, borrowed as a slice

For strings, the indices in these ranges are byte offsets, not character counts — important to remember once you work with non-ASCII text. The resulting type of a string slice expression is &str; the resulting type of slicing an array or Vec<T> of T is &[T].

Examples

The first example is the canonical motivating case: finding the first word of a sentence and returning a slice into the original string, rather than a copy.

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();
    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }
    &s[..]
}

fn main() {
    let sentence = String::from("hello world");
    let word = first_word(&sentence);
    println!("First word: {}", word);
}
First word: hello

The function scans the byte representation of s looking for the byte value of an ASCII space (b' '). As soon as it finds one at index i, it returns &s[0..i] — a slice covering everything before the space. If no space is found, it returns the whole string as a slice with &s[..]. Notice that first_word takes and returns &str, not String: this lets it accept a borrowed &String (via automatic deref coercion, as happens with &sentence) as well as plain string literals, without forcing an allocation.

The second example shows slicing on a fixed-size array and passing a slice to a function that works over any &[i32], whether it came from an array, part of an array, or a Vec.

fn sum_slice(numbers: &[i32]) -> i32 {
    let mut total = 0;
    for n in numbers {
        total += *n;
    }
    total
}

fn main() {
    let arr = [1, 2, 3, 4, 5];
    let slice = &arr[1..4];
    println!("Slice: {:?}", slice);
    println!("Sum of slice: {}", sum_slice(slice));
    println!("Sum of whole array: {}", sum_slice(&arr));
}
Slice: [2, 3, 4]
Sum of slice: 9
Sum of whole array: 15

&arr[1..4] borrows elements at indices 1, 2, and 3 (index 4 is excluded), giving [2, 3, 4]. Because sum_slice takes &[i32] rather than &[i32; 5] or &Vec<i32>, the exact same function works whether you pass a partial slice or a reference to the entire array (&arr coerces to &[i32] automatically). This is the core reason slice parameters are so common in Rust APIs: one function signature covers arrays, sub-ranges of arrays, and vectors alike.

The third example combines a slicing helper with the standard library’s own iterator-based splitting, which is what you would actually reach for in real code.

fn first_word(s: &str) -> &str {
    match s.find(' ') {
        Some(index) => &s[..index],
        None => s,
    }
}

fn main() {
    let text = String::from("the quick brown fox");
    let word = first_word(&text);
    println!("First word: {}", word);

    let words: Vec<&str> = text.split_whitespace().collect();
    println!("All words: {:?}", words);
    println!("Number of words: {}", words.len());
}
First word: the
All words: ["the", "quick", "brown", "fox"]
Number of words: 4

s.find(' ') returns an Option<usize>, and matching on it avoids ever calling .unwrap() on a value that might be None. text.split_whitespace() goes further: it returns an iterator that yields each word as its own &str slice into the original text, with no copying, which we collect into a Vec<&str>. All four words in the vector are slices borrowed from the same underlying text buffer.

How It Works Step by Step

When the compiler sees an expression like &s[0..5], it does roughly the following:

  • It resolves indexing through the Index trait implementation for the range type, which for String/str and for arrays/Vec<T> is provided by the standard library.
  • It computes a pointer to the element at the start of the range and pairs it with the range’s length, producing the two-word fat pointer described earlier — this is the actual runtime representation of &str and &[T].
  • For string slices specifically, it inserts a runtime check that both start and end land on valid UTF-8 character boundaries and are within bounds; violating either causes a panic, since returning a slice that split a multi-byte character in half would produce invalid UTF-8.
  • Separately, at compile time, the borrow checker records that the resulting slice borrows from the original collection for as long as the slice is used. Any attempt to mutate the collection (push, clear, reassign, take a &mut reference) while that borrow is still alive is rejected before the program ever runs.

The bounds and UTF-8 boundary checks happen at runtime because the compiler generally cannot know the exact byte length or contents of a string in advance; the borrow-lifetime check happens purely at compile time and costs nothing when the program runs.

Common Mistakes

Mistake 1: Mutating a Collection While a Slice Borrows From It

Holding a slice into a String and then mutating that String before you are done with the slice is rejected by the borrow checker, because the mutation could invalidate the memory the slice points to.

fn first_word(s: &str) -> &str {
    match s.find(' ') {
        Some(index) => &s[..index],
        None => s,
    }
}

fn main() {
    let mut s = String::from("hello world");
    let word = first_word(&s);
    s.clear();
    println!("the first word is: {}", word);
}

This fails to compile with a borrow-checker error: word holds an immutable borrow of s that is still needed at the println!, so the mutable borrow required by s.clear() conflicts with it. The fix is to make sure the slice’s last use happens before the mutation:

fn first_word(s: &str) -> &str {
    match s.find(' ') {
        Some(index) => &s[..index],
        None => s,
    }
}

fn main() {
    let mut s = String::from("hello world");
    let word = first_word(&s);
    println!("the first word is: {}", word);
    s.clear();
}
the first word is: hello

Because Rust uses non-lexical lifetimes, the immutable borrow held by word ends right after its last use in the println!, so s.clear() afterward is perfectly legal.

Mistake 2: Slicing Out of Bounds or Off a UTF-8 Boundary

Slice ranges are checked at runtime, not compile time, so an out-of-range slice compiles fine but panics when it runs.

fn main() {
    let s = String::from("hello");
    let slice = &s[0..10];
    println!("{}", slice);
}

Since "hello" is only 5 bytes long, this panics with a message like byte index 10 is out of bounds of \`hello\`, which contains 5 bytes. The same kind of panic happens if you slice a multi-byte UTF-8 character in half. When the range might be invalid, use .get(range) instead of direct indexing — it returns an Option<&str> instead of panicking:

fn main() {
    let s = String::from("hello");
    match s.get(0..10) {
        Some(slice) => println!("Slice: {}", slice),
        None => println!("Requested range is out of bounds"),
    }
}
Requested range is out of bounds

Mistake 3: Growing a Vector While Holding a Reference Into It

Pushing to a Vec can force it to reallocate to a new, larger buffer, which would leave any existing reference into the old buffer dangling. The borrow checker forbids this outright, even in cases where it happens not to reallocate.

fn main() {
    let mut v = vec![1, 2, 3, 4, 5];
    let first = &v[0];
    v.push(6);
    println!("The first element is: {}", first);
}

This fails because first is an immutable borrow of v that is still alive at the final println!, and v.push(6) needs a mutable borrow. If you only need the value, not a reference to it, copy it out instead — i32 implements Copy, so v[0] gives you an owned value with no borrow attached:

fn main() {
    let mut v = vec![1, 2, 3, 4, 5];
    let first = v[0];
    v.push(6);
    println!("The first element is: {}", first);
}
The first element is: 1

Best Practices

  • Prefer &str over &String for function parameters that only read string data; it accepts both owned String values (via deref coercion) and string literals.
  • Prefer &[T] over &Vec<T> for the same reason — it works with arrays, sub-slices, and vectors alike.
  • Reach for .get(index) or .get(range) instead of direct indexing whenever the bounds are not guaranteed valid; it returns an Option instead of panicking.
  • Remember that string slice indices are byte offsets, not character counts. Use .chars() or .char_indices() when you need to reason in terms of characters, especially with non-ASCII text.
  • Keep a slice’s borrow as short-lived as possible — make sure its last use happens before you need to mutate the collection it came from.
  • Favor iterator adapters (.iter(), .windows(), .chunks(), .split_whitespace()) over manual index arithmetic on slices; they are just as fast and eliminate whole categories of off-by-one bugs.

Practice Exercises

  • Write a function last_word(s: &str) -> &str that returns the last word of a sentence using slicing. Hint: .rfind(' ') searches from the end of the string.
  • Write a function evens(numbers: &[i32]) -> Vec<i32> that takes a slice and returns a new Vec<i32> containing only the even values, without modifying the original slice.
  • Write a small program that deliberately holds a slice into a String, then tries to call .push_str() on that String while the slice is still used afterward. Confirm it fails to compile, then fix it by reordering the code so the slice’s last use comes before the mutation.

Summary

  • A slice is a borrowed, contiguous view into part of a collection — it does not own or copy the underlying data.
  • A slice reference is a fat pointer: an address plus a length, stored together as one value.
  • &str is a slice of UTF-8 bytes; &[T] is a slice of any element type T, used with arrays and Vec<T>.
  • Range syntax like start..end, start.., ..end, ..=end, and .. controls exactly which elements a slice covers.
  • Because a slice is a borrow with a tracked lifetime, the compiler prevents you from mutating a collection while a slice into it is still alive — turning a whole class of stale-reference bugs into compile errors.
  • Slice indices for strings are byte offsets and must fall on UTF-8 character boundaries, or the program panics at runtime.
  • Prefer &str and &[T] as function parameter types over &String and &Vec<T> — they are strictly more flexible.