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.

TypeOwnershipMutabilityTypical use
StringOwns heap-allocated textYesBuilding, editing, storing text
&strBorrows textNoReading, 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.

ScenarioRecommended typeReason
Read-only input parameter&strFlexible and allocation-free
Text built inside a functionStringOwned result can be returned safely
Stored field in a structString or Cow<'a, str>Ownership usually required
Temporary view into existing text&strLightweight borrow
API accepting literals and strings&strBest 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 &str for read-only text.
  • Use String when 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.

Learn more with useful resources