Introduction
Slices are one of the most elegant features in Rust, yet many developers move past them without fully appreciating their power and safety guarantees. Slices in Rust: concepts, trade-offs and production lessons explores how borrowing parts of collections can eliminate entire categories of bugs while improving performance. In this article, we'll walk through practical patterns that we've refined across high-throughput systems.
&[T] for array and Vec slices
A slice is a view into a contiguous block of data. Unlike owning a Vec<T> or an array, a slice &[T] borrows the data without taking ownership. This means the original owner remains responsible for keeping the memory valid while we read through it.
Consider a real-world scenario: in our ride-hailing service, we process batches of GPS coordinates. Rather than passing entire vectors around, we pass slices:
fn calculate_distance_matrix(coords: &[(f64, f64)]) -> Vec<Vec<f64>> {
let n = coords.len();
let mut distances = vec![vec![0.0; n]; n];
for i in 0..n {
for j in i+1..n {
let dx = coords[i].0 - coords[j].0;
let dy = coords[i].1 - coords[j].1;
distances[i][j] = (dx * dx + dy * dy).sqrt();
distances[j][i] = distances[i][j];
}
}
distances
}
// Call site: works with Vec, array, or any contiguous data
let vehicles = vec![(40.7128, 74.0060), (40.7580, 73.9855)];
let matrix = calculate_distance_matrix(&vehicles);
let fixed_points: [(f64, f64); 3] = [
(40.7128, 74.0060),
(40.7580, 73.9855),
(34.0522, 118.2437),
];
let matrix2 = calculate_distance_matrix(&fixed_points);The slice parameter &[(f64, f64)] works seamlessly with both Vec and arrays. This flexibility is crucial in production: it lets us optimize storage (arrays for fixed sizes, vecs for dynamic) without rewriting functions. The borrow checker ensures no one modifies the coordinates while we're iterating, preventing race conditions at compile time.
Slices also avoid the hidden cost of heap allocations. A Vec<T> carries three words of metadata (pointer, capacity, length); a slice carries only two (pointer and length). In tight loops across millions of records, this matters.
&str for string slices
Strings in Rust come in two forms: String (owned, mutable, heap-allocated) and &str (borrowed, immutable view). A string slice is always valid UTF-8, which the compiler verifies. This guarantee eliminates entire classes of encoding bugs.
In our CRM system, we parse user input and extract fields without copying:
fn extract_email_domain(email: &str) -> Option<&str> {
let at_pos = email.find('@')?;
Some(&email[at_pos + 1..])
}
fn is_corporate_domain(domain: &str) -> bool {
domain.ends_with(".com") || domain.ends_with(".org")
}
let user_email = "alice@acme.com"; // &'static str
let domain = extract_email_domain(user_email)?;
if is_corporate_domain(domain) {
println!("Corporate user");
}
// Also works with owned strings
let dynamic_email = format!("bob@{}", "startup.io");
let domain2 = extract_email_domain(&dynamic_email)?;Notice we return Option<&str>, not Option<String>. The slice points back into the original string, avoiding allocation. The borrow checker ensures that domain cannot outlive the string it came from.
String slices are everywhere in the standard library: methods like split(), trim(), and contains() all work with &str. This consistency lets us build resilient parsers and validators without intermediate heap allocations.
Ranges like &v[1..3]
Rust's range syntax produces powerful slicing operations. The notation &v[start..end] creates a slice from index start up to (but not including) end. We can omit either bound: &v[..] means the whole slice, &v[2..] skips the first two elements, &v[..3] takes the first three.
In our trading system, we often need to process windowed data:
fn compute_moving_average(prices: &[f64], window: usize) -> Vec<f64> {
let mut averages = Vec::with_capacity(prices.len() - window + 1);
for i in 0..=prices.len() - window {
let slice = &prices[i..i + window];
let sum: f64 = slice.iter().sum();
averages.push(sum / window as f64);
}
averages
}
fn trim_outliers(data: &mut [f64]) {
// Mutate a slice in place
for value in data.iter_mut() {
if *value < 0.0 {
*value = 0.0;
}
}
}
let raw_prices = vec![100.0, 102.5, 101.8, 103.2, 102.0];
let mas = compute_moving_average(&raw_prices[1..4], 2); // Process a windowThe key insight: slicing is zero-copy. &prices[1..4] doesn't allocate or copy data; it creates a new slice pointing into the same buffer at a different offset. For datasets with millions of records, this performance difference is substantial.
We can also slice mutable data. &mut v[2..5] gives us a mutable borrow to elements 2–4. The compiler prevents us from aliasing mutable borrows, so concurrent access remains impossible.
Prefer slices in function parameters
One of our core practices in production code is: use slices in function signatures, not Vec<T>. This maximizes flexibility and decouples functions from storage details.
Here's the principle:
- Accept
&[T]when reading: Works with vecs, arrays, slices, and even stack-allocated data. - Accept
&mut [T]when modifying in place: Caller retains ownership; function just mutates. - Return
Vec<T>when creating new data: Caller gets ownership and control over lifetimes.
This pattern is evident across the Rust standard library. str::contains(&str), Vec::binary_search_by(&[T]), and slice::sort() all use slices. It's not an accident—it's good API design.
In our SaaS platform, we process event streams:
// Bad: unnecessarily owns a Vec
fn process_events_bad(events: Vec<Event>) {
for event in events {
println!("{:?}", event);
}
}
// Good: borrows a slice
fn process_events_good(events: &[Event]) {
for event in events {
println!("{:?}", event);
}
}
// Caller can pass whatever they have
let owned_vec = vec![Event::new(), Event::new()];
process_events_good(&owned_vec);
let array = [Event::new(), Event::new()];
process_events_good(&array);
// Even a reference to a vec works
let vec_ref = &owned_vec;
process_events_good(vec_ref);The slice-based signature is strictly more general. It costs nothing to accept &[T] instead of Vec<T>, and it frees callers from unnecessary allocations.
Common mistakes
1. Thinking slices clone data: They don't. A slice is a view. If the underlying data goes out of scope, the slice dangling is caught by the borrow checker.
// This won't compile
fn make_slice() -> &[i32] {
let v = vec![1, 2, 3];
&v // v is dropped; slice would dangle
}2. Returning slices of local data: Related to above. Functions that return references to stack data won't compile. Return an owned Vec<T> instead, or use a different lifetime strategy.
3. Mutable borrows in loops: A common gotcha is trying to borrow the whole slice mutably inside a loop, then index it. Use chunks_mut(), windows_mut(), or split methods instead:
// Works: borrows small slices in sequence
let mut data = vec![1, 2, 3, 4, 5, 6];
for chunk in data.chunks_mut(2) {
chunk[0] += 1;
}4. Off-by-one errors with ranges: Remember .. is exclusive on the right. &v[0..3] gives elements at indices 0, 1, 2. Use inclusive ranges &v[0..=3] if you want to include index 3.
Key takeaways
- Slices
&[T]borrow contiguous data without copying or allocating; they're zero-cost abstractions. - String slices
&strare always valid UTF-8 and the standard way to pass string data in Rust. - Range indexing like
&v[start..end]produces new slices pointing into the same data, not copies. - Use slice parameters in function signatures; they work with any contiguous storage and maximize API flexibility.
- The borrow checker prevents us from creating dangling slice references or aliasing mutable borrows, so slices are safe by design.
- In production systems handling millions of records, preferring slices over owned collections can significantly reduce allocations and improve throughput.






Comments
Loading comments…