The Slice Type in Rust
You now know how to borrow a whole value with &. But real programs rarely want the whole thing. You want the first word of a sentence. The file extension at the end of a path. Three elements out of a list of five.
Rust has one answer for all of those, and it is called a slice.
A slice borrows part of a value instead of all of it.
That is the entire idea. Everything else in this chapter is detail — but the detail is worth knowing, because slices are the single most common type in day-to-day Rust, and they turn one particularly nasty class of bug into a compile error.
This chapter assumes you are comfortable with & and &mut from References and Borrowing, and with String from Ownership. If either is hazy, five minutes there will make everything below click.
The Problem: A Number Is a Terrible Answer
Let us try to solve a small, ordinary task without slices, and watch it go wrong.
The task: given some text, find where the first word ends.
With only what you know so far, the best you can return is a number — the byte position where the first space appears:
fn main() {
let mut label = String::from("hermit crab");
let end = first_word_end(&label);
label.clear(); // label is now ""
println!("the first word ends at byte {end}");
}
fn first_word_end(text: &String) -> usize {
let bytes = text.as_bytes();
for (i, &byte) in bytes.iter().enumerate() {
if byte == b' ' {
return i;
}
}
text.len()
}Output:
the first word ends at byte 6Two lines of that function may be new, so quickly:
text.as_bytes()hands you the raw bytes of the text as a list you can walk through..iter().enumerate()walks that list and gives you both the positioniand the byte itself on every step.b' 'is a byte literal — the single byte for a space character. We compare bytes to bytes.&bytein the pattern destructures the reference the iterator hands out, sobyteis a plain number.
The function works. And the program is still badly broken.
Look at Where the Bug Is Hiding
Read main again. We asked for the end of the first word, got back 6, then emptied the string with label.clear(). And the program happily printed 6 anyway.
That 6 is now a lie. It describes text that no longer exists.
The number never changed. The thing it described did.
The deep problem is that end and label are two separate values that have to agree, and nothing in the language makes them agree. The compiler sees a usize and a String. It has no idea they are related, so it cannot warn you when one of them drifts out of date.
It gets worse the more positions you keep. Write a second_word function returning a start and an end and you now have three values that must stay in sync by hand:
fn second_word(text: &String) -> (usize, usize) { /* ... */ }
// ^^^^^ ^^^^^
// two more numbers you must not get wrongEvery one of them is a bug waiting for the day somebody edits the string.
This is the shape of an entire family of real-world bugs: an index, offset, or length stored separately from the data it refers to. Rust does not fix this by being careful. It fixes it by making the position part of a borrow, so the borrow checker can see the connection.
The Fix: Take a Slice
Instead of returning where the word is, return the word:
fn main() {
let species = String::from("hermit crab");
let genus = &species[0..6];
let kind = &species[7..11];
println!("genus: {genus}");
println!("kind: {kind}");
println!("owner is untouched: {species}");
}Output:
genus: hermit
kind: crab
owner is untouched: hermit crab&species[0..6] is a slice. Break the syntax into its three parts:
| Part | What it does |
|---|---|
& | This is a borrow — you are not taking ownership |
species | The value you are borrowing from |
[0..6] | The range: start at byte 0, stop before byte 6 |
So [0..6] covers bytes 0, 1, 2, 3, 4 and 5 — six bytes, hermit. The end of a range is always exclusive, which feels odd for about a day and then feels natural, because end - start is exactly the length.
What a Slice Looks Like in Memory
Here is the picture that makes slices click:
Three variables, one buffer. A slice is a window, not a copy.
A slice stores exactly two things:
- A pointer — where the run of data starts.
- A length — how many items are in it.
Notice what is missing: there is no capacity field. Capacity belongs to whoever owns and can grow the buffer. A slice cannot grow anything, so it does not need it.
You can watch the sizes yourself:
use std::mem::size_of;
fn main() {
println!("&String is {} bytes — just a pointer", size_of::<&String>());
println!("&str is {} bytes — pointer + length", size_of::<&str>());
println!("String is {} bytes — pointer + length + capacity", size_of::<String>());
}Output on a 64-bit machine:
&String is 8 bytes — just a pointer
&str is 16 bytes — pointer + length
String is 24 bytes — pointer + length + capacitySixteen bytes. That is the entire cost of a slice, no matter whether it points at six characters or six million.
A slice is a reference. Everything from the borrowing chapter applies unchanged: it borrows rather than owns, it frees nothing when it goes out of scope, you can have many shared slices at once, and the borrow checker tracks it. A slice is not a new concept bolted on — it is a reference that happens to know a length.
Range Syntax: All the Shortcuts
Rust lets you leave out the obvious parts of a range. All six lines below are valid:
fn main() {
let species = String::from("hermit crab");
println!("{}", &species[0..6]); // hermit
println!("{}", &species[..6]); // hermit
println!("{}", &species[7..11]); // crab
println!("{}", &species[7..]); // crab
println!("{}", &species[..]); // hermit crab
println!("{}", &species[0..=5]); // hermit
}Output:
hermit
hermit
crab
crab
hermit crab
hermitThe full set:
| Written | Means | On "hermit crab" |
|---|---|---|
&s[0..6] | From 0, stop before 6 | hermit |
&s[..6] | From the start, stop before 6 | hermit |
&s[7..11] | From 7, stop before 11 | crab |
&s[7..] | From 7 to the end | crab |
&s[..] | The whole thing, as a slice | hermit crab |
&s[0..=5] | From 0, including 5 | hermit |
Two shortcuts are worth committing to memory:
&s[..]turns any owned value into a slice of the whole thing. You will use it constantly...=makes the end inclusive. Use the plain..by default; reach for..=only when the inclusive reading is genuinely clearer.
&str: The String Slice Type
So what type did &species[0..6] produce? Not a String. It is a string slice, and its type is written &str.
You say it out loud as “string slice” or “a ref stir”. You will see it in nearly every Rust signature you ever read.
Here is the comparison that matters:
String | &str | |
|---|---|---|
| Owns its data? | ✅ Yes | ❌ No — it borrows |
| Can grow or shrink? | ✅ Yes (push_str, clear) | ❌ No |
| Frees memory when dropped? | ✅ Yes | ❌ Nothing to free |
| Size of the value itself | 24 bytes (ptr + len + capacity) | 16 bytes (ptr + len) |
| Costs an allocation to make? | ✅ Yes | ❌ Free |
| Made with | String::from("..."), .to_string() | &s[..], a literal, .as_str() |
The rule of thumb is short:
Stringis for owning text.&stris for reading text.
Most of your program reads text, so most of your program should be full of &str.
When in doubt, borrow. Own only when you must.
Careful: Ranges Count Bytes, Not Characters
Here is the one genuine trap in this chapter, and it catches everybody once.
Rust stores text as UTF-8. In UTF-8, a is one byte but é is two, 中 is three, and an emoji is usually four. Slice ranges are measured in bytes, so a range that looks harmless can land in the middle of a character:
fn main() {
let note = String::from("café kelp");
let first = ¬e[0..4];
println!("{first}");
}This compiles. At runtime it panics:
thread 'main' panicked at src/main.rs:4:22:
byte index 4 is not a char boundary; it is inside 'é' (bytes 3..5) of `café kelp`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtraceRead the message — it is unusually helpful. é occupies bytes 3 and 4, so cutting at byte 4 would slice it in half. Rust refuses, because half of a character is not valid text.
Rust panics here on purpose. It will not silently hand you a corrupted string, and it will not silently round your index to a “nearby” boundary. Broken text is never an acceptable answer, so the program stops instead.
Working With Multi-Byte Text Safely
Three tools cover almost everything:
fn main() {
let note = String::from("café kelp");
println!("byte length: {}", note.len());
println!("characters: {}", note.chars().count());
println!("correct slice: {}", ¬e[0..5]);
match note.get(0..4) {
Some(text) => println!("got {text}"),
None => println!("byte 4 is not a character boundary — no panic"),
}
}Output:
byte length: 10
characters: 9
correct slice: café
byte 4 is not a character boundary — no panic.len()is a byte count, not a character count. Ten bytes, nine characters. This is the single most misread method in Rust..get(range)does the same job as[range]but hands backNoneinstead of panicking. Use it whenever the range comes from user input or arithmetic you are not certain about.¬e[0..5]works because byte 5 is a real boundary —éends there.
And when you need to find a boundary, ask the string where its characters begin:
fn main() {
let note = "café";
for (index, character) in note.char_indices() {
println!("byte {index} starts {character}");
}
}Output:
byte 0 starts c
byte 1 starts a
byte 2 starts f
byte 3 starts échar_indices() gives you byte positions that are guaranteed safe to slice at.
The Other Panic: Out of Bounds
A range past the end of the text panics too, for the same reason — there is nothing there:
fn main() {
let species = String::from("hermit crab");
let bad = &species[0..40];
println!("{bad}");
}thread 'main' panicked at src/main.rs:4:23:
byte index 40 is out of bounds of `hermit crab`Again, .get(0..40) returns None instead if you would rather handle it than crash.
If your text is plain ASCII — English letters, digits, ordinary punctuation — bytes and characters are the same thing and none of this can bite you. It matters the moment real user data arrives, which in practice is always. Writing .get() from the start costs nothing.
Rewriting first_word Properly
Back to our function. Instead of usize, return &str:
fn main() {
let species = String::from("hermit crab");
let first = first_word(&species);
println!("first word: {first}");
}
fn first_word(text: &String) -> &str {
let bytes = text.as_bytes();
for (i, &byte) in bytes.iter().enumerate() {
if byte == b' ' {
return &text[0..i];
}
}
&text[..]
}Output:
first word: hermitOnly the highlighted lines changed. When we find a space we return &text[0..i] — everything before it. If there is no space at all, the whole text is one word, so we return &text[..].
Now Watch the Original Bug Become a Compile Error
This is the payoff. Take the exact broken main from the start of the chapter and run it against the new function:
fn main() {
let mut species = String::from("hermit crab");
let first = first_word(&species);
species.clear();
println!("first word: {first}");
}error[E0502]: cannot borrow `species` as mutable because it is also borrowed as immutable
--> src/main.rs:6:5
|
4 | let first = first_word(&species);
| -------- immutable borrow occurs here
5 |
6 | species.clear();
| ^^^^^^^^^^^^^^^ mutable borrow occurs here
7 |
8 | println!("first word: {first}");
| ------- immutable borrow later used here
For more information about this error, try `rustc --explain E0502`.The identical program that printed a meaningless 6 now refuses to compile.
Here is why, and it is worth reading twice. clear() needs to empty the string, so it takes a &mut borrow. But first is a slice — a shared borrow of that same string — and it is still alive, because line 8 uses it. You cannot have a shared borrow and a mutable borrow at the same time. That is rule one of borrowing, applied without any special cases.
Same program, same mistake. Returning a slice is what lets the compiler see it.
This is the real argument for slices. A number is just data. A slice is a borrow, and borrows are something the compiler already knows how to police.
String Literals Are Already Slices
Here is something you have been using since your very first Rust program without knowing what it was:
fn main() {
let motto = "crabs welcome";
println!("{motto}");
println!("{} bytes, type &str", motto.len());
println!("first word: {}", &motto[0..5]);
}Output:
crabs welcome
13 bytes, type &str
first word: crabsThe type of motto is &str. Not String — a string slice.
When you write a literal, the text is baked straight into your compiled program, and motto is a slice pointing at it. That explains two things that may have puzzled you:
- Why literals are immutable. They point into the read-only part of your binary. There is nothing to grow.
- Why
String::from("...")exists at all. It copies those bytes onto the heap so you get something you can own and change.
Try to modify a literal and the compiler is blunt about it:
fn main() {
let mut motto = "crabs welcome";
motto.push_str(" here");
}error[E0599]: no method named `push_str` found for reference `&str` in the current scope
--> src/main.rs:4:11
|
4 | motto.push_str(" here");
| ^^^^^^^^ method not found in `&str`Note that mut did not help. mut lets you point motto at a different slice; it does not give a slice powers it never had.
You will sometimes see a literal written as &'static str. The 'static part is a lifetime saying the text lives for the whole program — which it does, since it is part of the executable. Lifetimes get their own chapter; for now &str is all you need.
Always Take &str, Never &String
Our first_word still takes &String. That is a habit worth breaking on day one, because it makes the function work in fewer places for no reason at all.
Change one word in the signature:
fn main() {
let owned = String::from("hermit crab");
println!("{}", first_word(&owned)); // a &String — works
println!("{}", first_word(&owned[..])); // a slice of it — works
println!("{}", first_word("blue lobster")); // a literal — works
}
fn first_word(text: &str) -> &str {
let bytes = text.as_bytes();
for (i, &byte) in bytes.iter().enumerate() {
if byte == b' ' {
return &text[0..i];
}
}
text
}Output:
hermit
hermit
blueAll three calls compile. Passing &owned — a &String — into a &str parameter works because of deref coercion. Rust quietly rewrites &owned as &owned[..] at the call site.
The reverse does not work. Keep &String and the literal is rejected outright:
fn main() {
println!("{}", first_word("blue lobster"));
}
fn first_word(text: &String) -> &str {
&text[..]
}error[E0308]: mismatched types
--> src/main.rs:2:31
|
2 | println!("{}", first_word("blue lobster"));
| ---------- ^^^^^^^^^^^^^^ expected `&String`, found `&str`
| |
| arguments to this function are incorrect
|
= note: expected reference `&String`
found reference `&'static str`Rule of thumb: if a function only reads text, its parameter should be &str. Never &String. A &str parameter accepts literals, slices, sub-slices and &String alike; a &String parameter accepts exactly one of those. Experienced Rust reviewers will flag &String on sight.
Slices Work on Arrays and Vectors Too
Everything so far has been about text, but slicing is not a string feature. It works on any contiguous sequence, including arrays and vectors:
fn main() {
let weights = [3, 7, 12, 5, 9];
let middle = &weights[1..4];
println!("middle: {middle:?}");
println!("length: {}", middle.len());
assert_eq!(middle, [7, 12, 5]);
}Output:
middle: [7, 12, 5]
length: 3The type of middle is &[i32] — “a slice of i32”. Identical idea, identical layout:
Pointer plus length, exactly like &str. The only difference is what the elements are.
&str is really just a special case: it is a slice of bytes that Rust guarantees is valid UTF-8, which is why it gets its own name and its own printing behaviour.
One Function, Every Kind of Collection
This is where slice parameters earn their keep. Write a function that takes &[i32] and it works on arrays, on vectors, and on ranges of either:
fn main() {
let array = [3, 7, 12, 5, 9];
let vector = vec![40, 2];
println!("array total: {}", total(&array));
println!("vector total: {}", total(&vector));
println!("middle total: {}", total(&array[1..4]));
}
fn total(weights: &[i32]) -> i32 {
let mut sum = 0;
for weight in weights {
sum += weight;
}
sum
}Output:
array total: 36
vector total: 42
middle total: 24One function, three completely different callers. Had we written total(weights: &Vec<i32>), only the middle line would compile — the same mistake as &String, wearing a different hat.
Take
&[T], not&Vec<T>. Take&str, not&String. It is the same rule twice.
Mutable Slices
A slice can be mutable too. &mut [T] lets you change the elements in place — though never add or remove any, since a slice cannot resize what it does not own:
fn main() {
let mut weights = [3, 7, 12, 5, 9];
sort_part(&mut weights[1..4]);
println!("{weights:?}");
}
fn sort_part(part: &mut [i32]) {
part.sort();
}Output:
[3, 5, 7, 12, 9]Only the middle three were touched — 7, 12, 5 became 5, 7, 12, while index 0 and index 4 sat untouched on either side. The usual rules apply: the owner must be mut, and while the mutable slice is alive nothing else may borrow the array.
Why You Always Need the &
Try writing the parameter without a reference and the compiler stops you with a genuinely educational error:
fn total(weights: [i32]) -> i32 {
weights.iter().sum()
}error[E0277]: the size for values of type `[i32]` cannot be known at compilation time
--> src/main.rs:3:19
|
3 | fn total(weights: [i32]) -> i32 {
| ^^^^^ doesn't have a size known at compile-time
|
= help: the trait `Sized` is not implemented for `[i32]`
help: function arguments must have a statically known size, borrowed slices always have a known size
|
3 | fn total(weights: &[i32]) -> i32 {
| +A bare [i32] — like a bare str — has no fixed length, so the compiler cannot work out how much stack space the parameter needs. These are called dynamically sized types. Put an & in front and the problem disappears, because a reference is always the same size: one pointer plus one length.
That is why you see &str and &[i32] everywhere and bare str and [i32] essentially never.
Slices Are Everywhere in Real Rust
Once you recognise the shape, you start seeing it in every standard library method that splits text apart. They do not build new strings — they hand you slices of the one you already have:
fn main() {
let entry = "shelly hermit 42";
for field in entry.split_whitespace() {
println!("field: {field}");
}
let words: Vec<&str> = entry.split_whitespace().collect();
println!("{} fields, none of them allocated: {words:?}", words.len());
}Output:
field: shelly
field: hermit
field: 42
3 fields, none of them allocated: ["shelly", "hermit", "42"]split_whitespace, lines, trim, split, splitn — all of them return &str values pointing into the original text. Splitting a one-megabyte log file into a million fields allocates nothing. That is a large part of why Rust text processing is as fast as it is.
Putting It All Together
A small program using everything from this chapter: &str parameters, slices into slices, borrowing that ends at the right moment, and zero allocations in the parsing:
fn main() {
let mut log = String::from("shelly hermit 42\npinchy lobster 77");
summarise(&log); // a &String, accepted as a &str for free
log.push_str("\nbubbles crab 19"); // no borrow is alive, so this is fine
summarise(&log);
}
fn summarise(log: &str) {
println!("--- {} entries ---", log.lines().count());
for line in log.lines() {
let name = first_word(line);
let rest = &line[name.len()..];
println!(" {name:<8} -> {}", rest.trim());
}
}
fn first_word(text: &str) -> &str {
let bytes = text.as_bytes();
for (i, &byte) in bytes.iter().enumerate() {
if byte == b' ' {
return &text[..i];
}
}
text
}Output:
--- 2 entries ---
shelly -> hermit 42
pinchy -> lobster 77
--- 3 entries ---
shelly -> hermit 42
pinchy -> lobster 77
bubbles -> crab 19Four things worth noticing:
summarisetakes&str, and we call it with&log. Deref coercion makes that free, and the same function would accept a literal without a single change.log.lines()hands out&strslices, andfirst_word(line)slices those again. A slice of a slice is just another slice — there is no nesting and no extra cost.&line[name.len()..]is arithmetic on a slice length, and it is safe here becausenameended at a space, which is always a character boundary.log.push_str(...)compiles because by that line every slice from the firstsummarisecall has already been dropped. Borrows end at their last use, so the mutation is uncontested.
Error and Panic Cheat Sheet
Everything slices can throw at you, and what each one actually means:
| Error or panic | What it means | Usual fix |
|---|---|---|
| E0502 cannot borrow as mutable, also borrowed as immutable | You are holding a slice while trying to modify what it points into | Finish using the slice before mutating, or .to_string() it if you need both |
byte index N is not a char boundary | Your range cut a multi-byte UTF-8 character in half | Use .char_indices() to find real boundaries, or .get(range) to get None instead |
byte index N is out of bounds | The range runs past the end of the data | Check against .len() first, or use .get(range) |
E0308 expected &String, found &str | A parameter is typed &String and got a literal | Change the parameter to &str — it accepts everything |
E0277 the size for values of type str / [i32] cannot be known | You wrote a bare str or [T] without a reference | Add the &: &str, &[i32] |
| E0596 cannot borrow as mutable | You took &mut something[..] on a non-mut owner | Add mut to the owner: let mut weights = ... |
E0599 no method named push_str found for &str | You tried to grow a slice or a literal | You need an owned String — use .to_string() or String::from |
Every E#### code can be explained right in your terminal. Run rustc --explain E0277 — or any other code — for a full write-up with examples, no internet needed.
Summary
| You write | Type you get | Owns? | Notes |
|---|---|---|---|
String::from("hi") | String | ✅ | Heap-allocated, growable |
"hi" | &str | ❌ | A literal is already a slice |
&s[0..6] | &str | ❌ | Bytes 0–5, end exclusive |
&s[..] | &str | ❌ | The whole thing, as a slice |
&arr[1..4] | &[i32] | ❌ | Elements 1, 2, 3 |
&mut arr[1..4] | &mut [i32] | ❌ | Change in place, cannot resize |
s.get(0..6) | Option<&str> | ❌ | None instead of a panic |
The mental shortcuts worth keeping:
- A slice is a pointer plus a length. Nothing is copied, nothing is allocated.
- Ranges count bytes, and the end is exclusive.
[0..6]is six bytes. - Return a slice, not an index. An index can go stale; a slice is a borrow the compiler tracks.
- Take
&str, not&String. Take&[T], not&Vec<T>. Same rule, twice. - A literal is already a
&str. That is why you cannot grow one.
Frequently Asked Questions
What is a slice in Rust?
A slice is a reference to a contiguous run of elements inside a collection, rather than to the whole collection. It stores exactly two things: a pointer to where the run starts, and a length. Because a slice is a kind of reference it owns nothing and copies nothing, so creating one is effectively free. The two you meet first are the string slice (&str) and the array slice (&[T]).
What is the difference between String and &str in Rust?
String is an owned, growable, heap-allocated buffer of text that frees its memory when it goes out of scope. &str is a borrowed view into text somebody else owns — just a pointer and a length — so it can never be grown or freed. Use String when you need to build or keep text; use &str whenever you only need to read it. In practice most of a program reads text, so most of a program is full of &str.
How do I get a substring in Rust?
Take a slice with a range: &text[0..6] gives you the first six bytes as a &str. The start is inclusive, the end is exclusive, and nothing is copied — the result borrows the original text. If the range might not land on a character boundary, use text.get(0..6) instead, which returns Option<&str> rather than panicking.
Why does my Rust program panic with “byte index is not a char boundary”?
Because slice ranges count bytes, not characters, and Rust stores text as UTF-8 where one character can take one to four bytes. If your range starts or ends inside a multi-byte character, the result would not be valid text, so Rust stops the program rather than hand you something corrupted. Find safe positions with .char_indices(), or use .get(range) to receive None instead of a panic.
Should a Rust function take &str or &String?
Almost always &str. A &str parameter accepts string literals, slices, sub-slices and references to an owned String — that last one works automatically through deref coercion. A &String parameter accepts only the last of those and rejects literals with error E0308, so it works in strictly fewer places for no benefit whatsoever. The identical rule applies to &[T] versus &Vec<T>.
Does slicing a string in Rust copy the data?
No. A slice is a pointer plus a length — 16 bytes on a 64-bit machine — so creating one writes two machine words and never touches the underlying text. This is why methods like split_whitespace, lines and trim cost no allocations at all: every piece they return is a slice pointing into the original string.
Why can’t I use str or [i32] directly as a type?
Because their size is not known at compile time — they are dynamically sized types. The compiler cannot work out how much stack space a parameter of unknown length needs, so it reports error E0277: “the size for values of type [i32] cannot be known at compilation time”. Adding & fixes it, because a reference to a slice is always exactly one pointer plus one length.
Can a Rust slice be mutable?
Yes — &mut [T] (and &mut str, which is rare). A mutable slice lets you change elements in place, for example calling .sort() on part of an array, but it can never add or remove elements because it does not own the buffer. The owning variable must be declared mut, and while the mutable slice is alive no other borrow of that data may exist, exactly as the normal borrowing rules require.
Are &str and &[u8] the same thing?
Almost. Both are a pointer and a length, but &str carries a guarantee that &[u8] does not: its bytes are always valid UTF-8. That guarantee is what lets you print it as text and slice it safely. You can go from text to bytes for free with .as_bytes(), but going the other way requires a check, via std::str::from_utf8.
What’s Next?
That completes ownership. You can now hand a value away, lend it out whole, and lend out any part of it — and in every case the compiler is quietly checking that nobody is looking at data that has gone away.
Everything so far has worked with values Rust gave you: numbers, text, tuples, arrays. Next comes building your own types — grouping related data into a struct, giving it named fields, and attaching methods to it. Every rule from these last three chapters carries straight across, because your types follow exactly the same ownership and borrowing rules the built-in ones do.
Until then, go back over References and Borrowing with slices in mind — the E0502 error will read very differently now — or pick up anything you skipped from the full Rust tutorial.