
Rust String Slices: Working with Text Without Unnecessary Allocation
What a string slice is
A string slice is a borrowed view into UTF-8 text. Its type is &str, and it does not own the data it points to.
let s = String::from("hello world");
let hello = &s[0..5];
let world = &s[6..11];
println!("{hello} {world}");In this example, hello and world are slices into the same underlying string. No new string is allocated. The slice is just a reference to a range of bytes that Rust knows form valid UTF-8.
A key detail: Rust strings are UTF-8 encoded, so slicing by byte index must respect character boundaries. That is why string slicing is safe only when the start and end positions align with valid UTF-8 boundaries.
&str versus String
A common source of confusion is the difference between String and &str.
| Type | Ownership | Mutability | Typical use |
|---|---|---|---|
String | Owns heap-allocated text | Yes | Building, editing, storing text |
&str | Borrows text | No | Reading, parsing, passing text to functions |
Use String when you need to allocate or modify text. Use &str when you only need to read it.
fn greet(name: &str) {
println!("Hello, {name}");
}
fn main() {
let owned = String::from("Ada");
let literal = "Grace";
greet(&owned);
greet(literal);
}This is one of the most useful design patterns in Rust: write functions that accept &str so callers can pass string literals, borrowed parts of a String, or other string-like values converted to slices.
Creating string slices
There are several common ways to create a &str.
From a string literal
String literals are already &'static str, meaning they are string slices with a static lifetime.
let message: &str = "ready";From a String
You can borrow the entire string or a portion of it.
let s = String::from("rustacean");
let full: &str = &s;
let part: &str = &s[0..4];From a range using get
Indexing with [] panics if the range is invalid. The get method is safer because it returns Option<&str>.
let s = String::from("hello");
match s.get(0..2) {
Some(slice) => println!("{slice}"),
None => println!("invalid slice"),
}For production code, get is often preferable when the range may come from user input or parsing logic.
Why byte indexing is tricky
Rust strings are UTF-8, so not every byte offset is a valid character boundary. This matters especially with non-ASCII text.
let s = String::from("Здравствуйте");
// This may panic if the range splits a character boundary.
let first = &s[0..2];The string above contains Cyrillic characters, which use more than one byte each. A range like 0..2 may cut through a character and fail.
If you need to work with characters, use .chars() instead of byte indexing.
let s = "Здравствуйте";
for ch in s.chars().take(3) {
println!("{ch}");
}Rule of thumb
- Use byte ranges only when you know the boundaries are valid.
- Use
chars()when you care about Unicode scalar values. - Use
char_indices()when you need both character positions and values.
Passing slices to functions
One of the best uses of string slices is API design. Accepting &str makes your functions more ergonomic and reusable.
fn is_blank(input: &str) -> bool {
input.trim().is_empty()
}
fn main() {
let a = " ";
let b = String::from("text");
println!("{}", is_blank(a));
println!("{}", is_blank(&b));
}The function works with both literals and owned strings. This avoids forcing callers to allocate or clone text just to satisfy a function signature.
Prefer borrowed input for read-only operations
If a function only inspects text, use &str:
- validation
- parsing
- searching
- formatting
- logging
If a function needs to store or mutate the text, it may need String or another owned type.
Common string-slice operations
Rust provides many useful methods directly on &str.
Trimming whitespace
let input = " hello ";
let cleaned = input.trim();
assert_eq!(cleaned, "hello");Checking prefixes and suffixes
let path = "/api/v1/users";
assert!(path.starts_with("/api"));
assert!(path.ends_with("users"));Searching for text
let text = "error: file not found";
if let Some(pos) = text.find("file") {
println!("found at byte index {pos}");
}Splitting text
let csv = "red,green,blue";
for part in csv.split(',') {
println!("{part}");
}These methods are especially useful in parsers, configuration loaders, and request handlers.
Building a small parser with slices
String slices shine when you need to process text without copying. Consider a simple command parser that accepts inputs like set timeout=30.
fn parse_setting(input: &str) -> Option<(&str, &str)> {
let input = input.trim();
let (key, value) = input.split_once('=')?;
let key = key.trim();
let value = value.trim();
if key.is_empty() || value.is_empty() {
return None;
}
Some((key, value))
}
fn main() {
let line = "timeout = 30";
match parse_setting(line) {
Some((key, value)) => println!("key: {key}, value: {value}"),
None => println!("invalid setting"),
}
}This example returns borrowed slices into the original input. That means:
- no allocation for the parsed parts
- no copying of text
- efficient handling of many lines or large inputs
This pattern is common in log parsing, protocol handling, and configuration processing.
Returning string slices safely
A function can return a &str only if the returned slice refers to data that lives long enough. In practice, this usually means returning a slice borrowed from an input parameter.
fn first_word(s: &str) -> &str {
match s.find(' ') {
Some(pos) => &s[..pos],
None => s,
}
}This works because the returned slice comes from the input string. The borrow checker ensures the caller keeps the source text alive long enough.
What not to do
You cannot return a slice to a local String created inside the function, because that string is dropped when the function ends.
fn bad() -> &str {
let s = String::from("temporary");
&s[..]
}This does not compile, and that is a good thing. If a function needs to produce text that outlives the function body, return String instead.
When to choose &str or String
The choice depends on ownership and lifetime needs.
| Scenario | Recommended type | Reason |
|---|---|---|
| Read-only input parameter | &str | Flexible and allocation-free |
| Text built inside a function | String | Owned result can be returned safely |
| Stored field in a struct | String or Cow<'a, str> | Ownership usually required |
| Temporary view into existing text | &str | Lightweight borrow |
| API accepting literals and strings | &str | Best ergonomics |
A good default is to accept &str and return String only when you must create new text.
Avoiding common mistakes
1. Slicing by arbitrary byte positions
Do not assume character positions match byte positions. This is especially dangerous with international text.
If you need to slice by visible characters, iterate over chars() and compute boundaries carefully.
2. Overusing String
Creating a String too early can lead to unnecessary allocations. If you only need to inspect input, borrow it first.
3. Returning borrowed text from owned temporaries
A slice must not outlive its source. If the source is local to the function, return an owned string instead.
4. Ignoring invalid ranges
Use get when a slice range may be uncertain. It avoids panics and makes error handling explicit.
Practical best practices
Accept borrowed text in public APIs
If your function does not need ownership, prefer:
fn process(input: &str) { /* ... */ }This keeps the API easy to use and avoids forcing callers to allocate.
Use String only when mutation or ownership is required
Examples include:
- concatenating text
- building output incrementally
- storing user input in a struct
- returning generated content
Prefer iterator-based text processing
Methods like split, lines, chars, and char_indices often lead to clearer and safer code than manual indexing.
Keep UTF-8 in mind
Rust’s string model is strict by design. That strictness prevents many bugs, but it also means text processing should be done with the right abstraction, not with raw byte assumptions.
A realistic example: parsing log lines
Suppose you want to parse log lines in the form:
INFO 2026-04-28 service started
You can extract the level and message using slices.
fn parse_log(line: &str) -> Option<(&str, &str)> {
let line = line.trim();
let (level, rest) = line.split_once(' ')?;
let (_, message) = rest.split_once(' ')?;
Some((level, message))
}
fn main() {
let line = "INFO 2026-04-28 service started";
if let Some((level, message)) = parse_log(line) {
println!("level: {level}");
println!("message: {message}");
}
}This approach is efficient because it borrows from the original line instead of allocating intermediate strings. It is also easy to extend with validation rules, such as checking that the level is one of a fixed set of values.
Summary
String slices are one of Rust’s most practical tools for working with text. They let you borrow UTF-8 data without copying, write flexible APIs, and build efficient parsers and validators. The main discipline is to respect UTF-8 boundaries and ownership rules.
If you remember only a few points, make them these:
- Use
&strfor read-only text. - Use
Stringwhen you need ownership or mutation. - Avoid arbitrary byte slicing unless you know the boundaries are valid.
- Prefer
get,split_once,trim, and iterator methods for safe text handling.
With these habits, your Rust code will be both more ergonomic and more robust.
