The Slice Type
A deep dive into Rust's slice type - a non-owning reference to a contiguous sequence of elements that solves the problem of indexing into collections while preserving memory safety and ownership guarantees.
The Slice Type
When you hold a reference to a String or a vector, you’re looking at the whole collection. But many real-world tasks need only a piece of it — the first word of a sentence, a sub-array of sensor readings, a header in a binary buffer. Without a direct way to talk about a sub-range, you’d have to track indices yourself, and those indices can easily fall out of sync with the original data. Rust’s slice type gives you a reference that points to a contiguous segment of a collection, never the whole thing, and it ties that reference to the original data so the compiler can catch the mistakes that indices can’t.
The Problem Indexes Create
Before slices, the natural approach is to return the position where the interesting piece ends. For example, a function that finds the end of the first word in a string might return a usize:
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
s.len()
}
The logic is straightforward: walk through the bytes of the string, and if you find a space, that’s the index where the first word ends. If there’s no space, the whole string is one word, so return its length.
The number you get back is correct at the moment you call the function, but it’s a bare integer. Nothing in the type system connects that usize to the String it came from. The program can compile perfectly and still let you use the index after the string has been cleared or modified:
fn main() {
let mut s = String::from("hello world");
let index = first_word(&s); // index == 5
s.clear(); // s is now ""
// index is still 5, but using it to slice s would be nonsense
println!("first word ends at index: {}", index);
}
Decoupled State:
The index 5 is meaningful only as long as s remains unchanged. Once s.clear() runs, the index is no longer tied to the actual content. Real-world code often uses that stale index later, silently producing incorrect results or panics.
This brittleness scales poorly. If you need start and end indices for the second word, you’re juggling multiple unconnected numbers. Any modification to the string invalidates all of them, but the compiler won’t stop you from using them.
A Window Instead of a Number
A slice replaces the bare index with a reference that points directly at the sub-range. It is not a copy of the elements — it’s a lightweight view with a start and a length.
For a string, you can create a slice by specifying a range inside square brackets:
let s = String::from("hello world");
let hello: &str = &s[0..5];
let world: &str = &s[6..11];
hello now refers to the bytes "hello" inside s. The type &str (pronounced “string slice”) is a reference to a sequence of UTF-8 characters that lives somewhere else. It doesn’t own the underlying memory; it borrows it.
Because a slice borrows from the collection, the borrow checker ties their lifetimes together. The moment you try to mutate the collection while a slice is still alive, the compiler will reject the code.
Slices Are References:
A slice is always a borrowed reference. It never takes ownership of the data, so when the slice goes out of scope nothing is deallocated — the original collection remains responsible for cleanup.
Creating and Using Slices
The general syntax for a slice is &collection[start..end], where start is the index of the first element you want and end is the index one past the last element you want. Rust’s range syntax gives you several shortcuts:
&s[..end]— start from the beginning.&s[start..]— go to the end.&s[..]— the entire collection as a slice.
These shortcuts are identical to their expanded forms. &s[..s.len()] and &s[..] produce the same slice.
let s = String::from("hello");
let slice1 = &s[0..2]; // "he"
let slice2 = &s[..2]; // same as above
let slice3 = &s[3..]; // "lo"
let slice4 = &s[..]; // "hello"
Array slices work the same way, producing a reference type written as &[T]:
let arr = [10, 20, 30, 40, 50];
let middle: &[i32] = &arr[1..4]; // [20, 30, 40]
In both cases, the slice is a “fat pointer”: it stores both a pointer to the first element and the number of elements it covers. This extra length field is what makes a slice different from a plain reference. With a plain &i32, the compiler only knows the address of one integer. With &[i32], it knows the address of the first integer and how many integers follow — enough to safely iterate, index, or pass the slice to functions that expect a sequence.
No Allocation:
Creating a slice never allocates new memory. The slice itself is just a pair of numbers (pointer + length) that lives on the stack. The original data stays exactly where it was.
String Slices in Depth
The &str type appears everywhere in Rust: string literals are &str, function parameters often use &str instead of &String, and many standard library methods work with &str. A &str can point to the heap memory of a String, to static memory embedded in the binary, or to a sub-range of another &str.
Rewriting the first_word function to return a &str eliminates the stale-index problem:
fn first_word(s: &String) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[..i];
}
}
&s[..]
}
The return type &str means the caller receives a borrow of part of s. This has an immediate effect on what the caller can do:
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s);
// s.clear(); // error: cannot borrow `s` as mutable because
// it is also borrowed as immutable
println!("First word: {}", word);
}
Because word is an immutable borrow of s, calling s.clear() (which requires a mutable borrow) is forbidden as long as word is still in use. The compiler enforces that the slice cannot outlive the data it points to, and that the data cannot be mutated while a slice is looking at it.
UTF-8 and Slice Boundaries
Rust strings are guaranteed to be valid UTF-8. Characters like 'é' can take up two bytes, and some characters use three or four. If you try to create a slice in the middle of a multi-byte character, the program will panic at runtime:
Panic on Invalid Boundary:
Slicing a String at an index that doesn’t fall on a character boundary causes the program to panic. For example, &s[0..1] on a string starting with a two-byte character like 'é' will terminate. Always ensure your indices align with character boundaries when handling non-ASCII text.
For this introduction, examples use ASCII to keep indices predictable. When you work with real-world text, prefer methods like .chars(), .char_indices(), or the unicode-segmentation crate to find safe split points.
Array Slices
String slices are the most common variety, but slices work on any contiguous sequence. An array slice &[T] gives you a view into a fixed-size array or a Vec<T>.
let numbers = [1, 2, 3, 4, 5];
let first_three: &[i32] = &numbers[..3];
assert_eq!(first_three, &[1, 2, 3]);
Just like with &str, this slice borrows from numbers and prevents the original from being mutated or dropped while the slice is alive.
Mutable Slices
If you need to modify the elements within the slice, you can use a mutable slice &mut [T]:
let mut values = [10, 20, 30, 40, 50];
let slice: &mut [i32] = &mut values[1..4];
slice[0] = 200;
// values is now [10, 200, 30, 40, 50]
A mutable slice gives you write access to the elements in its range, but it also enforces exclusive access. While slice exists, you cannot have any other mutable or immutable borrow of values. This is the same rule as any mutable reference: only one at a time. The slice borrows the entire collection, not just the sliced portion, for the purpose of the borrow checker — so you cannot, for instance, take two non-overlapping mutable slices simultaneously through separate variables.
Slices and Ownership
Slices fit into the ownership system as a borrowing tool. They don’t own data, so they don’t trigger drops. They impose the same borrowing rules as other references:
- You can have as many immutable slices as you want.
- You can have exactly one mutable slice, and no other references at all while it exists.
These rules mean that when a function returns a slice, the caller knows the data is still valid and won’t be mutated from underneath them — a guarantee a bare index could never provide.
Slices Don't Implement Drop:
Because a slice doesn’t own the memory it points to, it has no Drop implementation. When a &str or &[T] goes out of scope, nothing happens to the underlying collection. The ownership and deallocation remain the responsibility of the original owner.
Common Mistakes
Returning a slice that outlives its source. A slice is valid only as long as the collection it borrows from. If you try to return a slice of a String created inside a function, the compiler will reject it with a lifetime error because the String is dropped at the end of the function.
Indexing past the slice’s bounds. Just like with arrays, accessing slice[10] when the slice has only 5 elements panics at runtime. Use .get() if you want an Option instead.
Slicing a string in the middle of a multi-byte character. This panics. Always double-check that your indices sit at character boundaries when you’re working with non-ASCII text.
Expecting the borrow checker to allow overlapping mutable slices. Even if you create two mutable slices on non-overlapping ranges, Rust’s current borrow checker sees a second mutable borrow of the whole collection and rejects it. You’d need to split the slice using methods like .split_at_mut() to get multiple disjoint mutable views safely.
Mutable Slice Exclusivity:
A mutable slice borrows the entire collection, not just the range it covers. To get two writable windows into different parts of the same vector, use vec.split_at_mut(index) which returns two non-overlapping &mut [T] slices with proper compiler guarantees.
Putting It Together: A Concrete Example
Here’s a small program that processes a sentence and extracts words as string slices, demonstrating how the borrow checker prevents accidental mutation while the slices are in use:
fn main() {
let mut sentence = String::from("The quick brown fox");
let first = first_word(&sentence);
println!("First word: {}", first); // prints "The"
// sentence.push_str(" jumps"); // would not compile
// because `first` still borrows `sentence` immutably
println!("Sentence is still: {}", sentence);
}
fn first_word(s: &str) -> &str { // accept &str, not &String
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[..i];
}
}
&s[..]
}
The parameter type &str instead of &String makes the function more flexible: you can pass a &String, a &str, or a string literal, and it will still work. This is a standard Rust pattern — accept &str when you need to look at string data without taking ownership.
Compile-Time Safety:
If you see this code compile and run, the Rust compiler has already verified that no slice outlives its source and that no data is mutated through an active immutable borrow. The same logic that caused earlier examples to fail is now actively protecting your program.
Summary
Slices replace the fragile practice of tracking indices with a compiler-checked reference to a contiguous range of a collection. They are a direct application of Rust’s borrowing rules: a slice is an immutable or mutable borrow of part of a larger value, and the borrow checker treats it exactly like any other reference. This means you can trust that a returned &str or &[T] is always valid for the lifetime it claims, and that the original data won’t change underneath it in ways that would cause silent corruption.
Slices also serve as the foundation for many of Rust’s most ergonomic patterns — accepting &str in function parameters, working with sub-vectors through &[T], and safely splitting mutable data with split_at_mut.