Slices were the first Rust feature that made me stop and think “wait, this is actually a clever solution to a problem I didn’t realize I had.” Coming from C++, I was used to passing around pointers and lengths separately, hoping I hadn’t mismatched them. Rust bundles a pointer and a length into a single, borrow-checked type, and suddenly a whole category of bugs I used to accept as normal just… stopped happening.
In this article I’ll cover ranges (0..5, 0..=5, etc.) and slices (&[T], &str) together, because in practice they’re used constantly side by side — ranges are how you index into slices, and slices are how you work with borrowed, contiguous sequences of data.
Ranges: Expressing a Sequence of Values
A range in Rust is a value produced by the .. and ..= syntax. It’s not magic — it’s just a struct.
fn main() {
let r = 0..5; // Range<i32>
println!("{:?}", r);
for i in 0..5 {
print!("{} ", i);
}
println!();
for i in 0..=5 {
print!("{} ", i);
}
println!();
}
Output:
0..5
0 1 2 3 4
0 1 2 3 4 5
0..5is aRange<i32>— exclusive of the end (0 through 4).0..=5is aRangeInclusive<i32>— inclusive of the end (0 through 5).
Both types implement Iterator, which is why you can for-loop over them directly. This ties directly into how ranges are used for slicing: Range is not just for loops, it’s also the type accepted by indexing operations.
fn main() {
let v = vec![10, 20, 30, 40, 50];
println!("{:?}", &v[1..3]); // exclusive: indices 1, 2
println!("{:?}", &v[1..=3]); // inclusive: indices 1, 2, 3
println!("{:?}", &v[..2]); // from start
println!("{:?}", &v[2..]); // to end
println!("{:?}", &v[..]); // whole thing
}
Output:
[20, 30]
[20, 30, 40]
[10, 20]
[30, 40, 50]
[10, 20, 30, 40, 50]
Slices: A Borrowed View Into a Sequence
A slice, written &[T], is a view into a contiguous block of memory — it does not own the data. Internally, a slice reference is a fat pointer: it stores both a pointer to the first element and a length.
fn main() {
let arr = [1, 2, 3, 4, 5];
let slice: &[i32] = &arr[1..4];
println!("slice: {:?}", slice);
println!("length: {}", slice.len());
}
Output:
slice: [2, 3, 4]
length: 3
This is why passing a slice to a function is cheap regardless of the size of the underlying data — you’re copying a pointer and a length (16 bytes on a 64-bit system), not the elements themselves.
fn sum_slice(s: &[i32]) -> i32 {
s.iter().sum()
}
fn main() {
let v = vec![1, 2, 3, 4, 5];
let arr = [10, 20, 30];
// Works with Vec, arrays, and slices of slices - all coerce to &[i32]
println!("{}", sum_slice(&v));
println!("{}", sum_slice(&arr));
println!("{}", sum_slice(&v[1..3]));
}
Output:
15
60
5
This is one of the most practical lessons I learned: write functions that take &[T] instead of &Vec<T>. A &Vec<T> only accepts vectors, but &[T] accepts vectors, arrays, and slices of either — via automatic deref coercion. It’s a strictly more flexible signature with no downside.
String Slices: &str
Strings get their own dedicated slice type, &str, which is a view into UTF-8 encoded bytes. Every string literal in Rust is actually a &'static str.
fn main() {
let greeting = String::from("Hello, Rust world!");
let hello = &greeting[0..5];
let world = &greeting[7..12];
println!("{}", hello);
println!("{}", world);
}
Output:
Hello
Rust
There’s a subtlety here that bit me early on: &str slicing uses byte indices, not character indices. Because Rust strings are UTF-8, slicing at a byte boundary that falls in the middle of a multi-byte character causes a runtime panic.
fn main() {
let s = "héllo"; // é is 2 bytes in UTF-8
let bad = &s[0..2]; // panics: not a char boundary
println!("{}", bad);
}
Output:
thread 'main' panicked at src/main.rs:3:16:
byte index 2 is not a char boundary; it is inside 'é' (bytes 1..3) of `héllo`
For safe, character-aware slicing on non-ASCII text, I use .chars() combined with .take()/.skip(), or the char_indices() method to find valid boundaries first.
Ownership and Borrowing With Slices
Because a slice is a borrow, not an owner, the borrow checker enforces the same rules that apply to any reference: you can have many immutable slices, or exactly one mutable slice, but not both at once.
fn main() {
let mut v = vec![1, 2, 3, 4, 5];
let slice1 = &v[0..2];
let slice2 = &v[2..4];
println!("{:?} {:?}", slice1, slice2); // fine: two immutable borrows
let m = &mut v[0..2];
m[0] = 100;
println!("{:?}", v);
}
Output:
[1, 2] [3, 4]
[100, 2, 3, 4, 5]
This next example is the classic “why won’t this compile” moment for beginners:
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0];
v.push(4); // error: cannot borrow `v` as mutable while borrowed as immutable
println!("{}", first);
}
The compiler rejects this because v.push(4) might reallocate the vector’s backing buffer, which would leave first pointing at freed memory — exactly the kind of dangling-pointer bug that Rust’s borrow checker exists to prevent. This is memory safety enforced entirely at compile time, with zero runtime cost.
split_at, chunks, and windows: Practical Slice Methods
fn main() {
let data = [1, 2, 3, 4, 5, 6];
// split_at: divide into two slices at an index
let (left, right) = data.split_at(3);
println!("left: {:?}, right: {:?}", left, right);
// chunks: fixed-size non-overlapping groups
for chunk in data.chunks(2) {
println!("chunk: {:?}", chunk);
}
// windows: fixed-size overlapping groups
for window in data.windows(3) {
println!("window: {:?}", window);
}
}
Output:
left: [1, 2, 3], right: [4, 5, 6]
chunk: [1, 2]
chunk: [3, 4]
chunk: [5, 6]
window: [1, 2, 3]
window: [2, 3, 4]
window: [3, 4, 5]
window: [4, 5, 6]
I use chunks constantly for batch-processing data (like sending records to an API in groups of 50) and windows for anything involving comparing neighboring elements, like detecting consecutive increases in a series of numbers.
Real-World Example: Parsing a CSV-Style Line
fn parse_row(line: &str) -> Vec<&str> {
line.split(',').map(|field| field.trim()).collect()
}
fn main() {
let row = "Ayesha, 29, Lahore";
let fields = parse_row(row);
println!("{:?}", fields);
if let [name, age, city] = fields.as_slice() {
println!("{} is {} years old and lives in {}", name, age, city);
}
}
Output:
["Ayesha", "29", "Lahore"]
Ayesha is 29 years old and lives in Lahore
That if let [name, age, city] = fields.as_slice() line is slice pattern matching — one of my favorite lesser-known Rust features. It destructures a slice by shape, and only matches if the slice has exactly three elements, which makes malformed input fail safely instead of panicking on an out-of-bounds index.
Performance Notes
Slices carry zero runtime overhead beyond the pointer-and-length pair itself. Iterating a slice compiles to the same tight loop as iterating a raw array in C. Bounds checking does happen on indexing (v[i]) to guarantee memory safety, but the compiler frequently eliminates redundant checks when it can prove an index is in range (for example, inside a for loop over 0..v.len()). When it can’t prove that, and you’ve already validated the bounds yourself, .get_unchecked() exists for the rare case where you need to skip the check — though I’ve needed it maybe twice in years of writing Rust, and only in tight numerical loops.
Best Practices
- Accept
&[T]in function signatures, not&Vec<T>, for maximum flexibility. - Use
.get(i)instead ofv[i]when the index isn’t guaranteed to be valid — it returnsOption<&T>instead of panicking. - Be careful with
&strbyte-slicing on non-ASCII text; prefer.chars(),.char_indices(), or crates likeunicode-segmentationfor correctness. - Use slice patterns (
if let [a, b, c] = ...) for destructuring fixed-size or variable-size data safely.
Common Mistakes
- Slicing a
&strat a non-char-boundary byte index, causing a panic on non-ASCII input. - Holding an immutable slice across a mutation of the original collection, which the borrow checker will (correctly) reject.
- Using
&Vec<T>in function signatures instead of the more general&[T]. - Forgetting ranges are half-open by default (
0..5excludes 5), leading to off-by-one confusion — especially for anyone coming from a language with inclusive ranges by default.
FAQs and Troubleshooting
Q: What’s the difference between an array, a Vec, and a slice? A: An array ([T; N]) has a fixed, compile-time-known size and lives on the stack (unless boxed). A Vec<T> is a heap-allocated, growable owner of its data. A slice (&[T]) is a borrowed, non-owning view into either one.
Q: Why did my slice indexing panic with “index out of bounds”? A: Your range extends beyond the length of the underlying collection. Double-check off-by-one errors, especially with exclusive (..) vs inclusive (..=) ranges.
Q: Can I mutate through a slice? A: Yes, using &mut [T], obtained via .as_mut_slice(), &mut v[..], or similar. Ownership rules still apply — one mutable borrow at a time.
Q: How do I safely slice a string with non-ASCII characters? A: Use .chars() combined with .take(n) and .collect::<String>(), or find a valid boundary with .char_indices() before slicing by byte range.
Summary
Ranges and slices might look like small syntactic conveniences, but together they express one of Rust’s core ideas: you can work with a subset or sequence of data without copying it and without giving up memory safety. Ranges describe a sequence of indices or values; slices are the borrowed, bounds-checked windows into actual data that ranges are so often used to create. Once I started writing functions that accepted &[T] and &str by default instead of owned types, my Rust code became both faster and more flexible — which is a rare combination to get for free.