How does borrowing work with slices

Borrowing with slices allows you to reference a specific range of data within a collection without taking ownership of the entire collection.

The window into your data

You are building a log parser. Each line contains a timestamp, a severity level, and a message. You need to extract the severity level to route the log to the right handler. Copying the severity string creates a new allocation. The severity is already in memory. You just need a way to point to that specific part of the line without taking ownership.

Rust gives you slices for this. A slice is a view into a contiguous sequence of data. It borrows the data from its source. It does not own the data. It does not copy the data. It lets you work with a subset of a collection while the original collection stays alive and unchanged.

What a slice really is

Think of a slice like a camera viewfinder. The photograph is the full collection. The viewfinder shows a rectangular window into that photograph. You can move the viewfinder. You can resize the window. The photograph itself never moves. The viewfinder is just a description of where to look and how much to see.

In Rust, a slice is a "fat pointer." A regular reference like &i32 is a single memory address. A slice reference like &[i32] contains two pieces of information: a pointer to the first element and a length. The compiler uses the length to enforce bounds checks. You cannot read past the end of the slice because the slice knows its own size.

This structure means a slice is always tied to the lifetime of the data it points to. If the underlying data is dropped, the slice becomes invalid. The borrow checker enforces this rule at compile time. You cannot hold a slice to data that no longer exists.

A slice is a view. The data lives elsewhere.

Slicing syntax and basics

You create a slice using range syntax. The syntax start..end selects elements from start up to, but not including, end. The result is a reference to a slice.

fn main() {
    let numbers = [10, 20, 30, 40, 50];

    // Slice from index 1 up to index 4.
    // This creates a &[i32] borrowing the middle elements.
    // The original array remains untouched.
    let middle = &numbers[1..4];
    println!("{:?}", middle); // [20, 30, 40]

    // Slice from the beginning up to index 3.
    // The start index is optional; omit it to begin at 0.
    let prefix = &numbers[..3];
    println!("{:?}", prefix); // [10, 20, 30]

    // Slice from index 2 to the end.
    // The end index is optional; omit it to go to the end.
    let suffix = &numbers[2..];
    println!("{:?}", suffix); // [30, 40, 50]
}

The range syntax works on arrays, vectors, and strings. The type of the result depends on the collection. Slicing an array of integers gives &[i32]. Slicing a string gives &str. The syntax is consistent across types.

Convention aside: when you need the entire collection as a slice, write &arr instead of &arr[..]. The compiler coerces &[T; N] to &[T] automatically. The shorter form is idiomatic and preferred.

The range syntax is your tool. Use it to carve out what you need.

How the compiler tracks slices

Slices introduce lifetimes into your code. A lifetime is a region of the program where a reference is valid. When you create a slice, the compiler tracks the relationship between the slice and the original data.

If you try to mutate the original data while a slice exists, the compiler rejects the code. The slice might point to data that is changing. Allowing mutation would break memory safety.

fn main() {
    let mut data = vec![1, 2, 3, 4];

    // Create a slice borrowing the first two elements.
    let slice = &data[..2];

    // This line causes a compile error.
    // E0502: cannot borrow `data` as mutable because it is also borrowed as immutable.
    data.push(5);

    println!("{:?}", slice);
}

The error E0502 tells you exactly what went wrong. You have an immutable borrow active. You cannot take a mutable borrow while the immutable borrow exists. The compiler prevents data races and use-after-free bugs by enforcing this rule.

You can fix this by ending the borrow before mutating. Drop the slice or move the mutation earlier. The borrow checker requires you to structure your code so that borrows do not overlap with mutations.

The borrow checker tracks the slice lifetime. If the data dies, the slice dies.

The magic of coercion

Rust makes slices incredibly flexible through coercion. Coercion is an automatic conversion the compiler performs in certain contexts. You can pass a Vec<T> where a &[T] is expected. You can pass an array where a slice is expected. The compiler handles the conversion silently.

/// Processes a sequence of bytes without caring about the source.
/// Accepts slices, vectors, and arrays via coercion.
fn analyze(data: &[u8]) -> usize {
    // Count non-zero bytes.
    data.iter().filter(|&&b| b != 0).count()
}

fn main() {
    let vec_data = vec![1, 0, 3, 0, 5];
    let arr_data = [10, 20, 30];

    // Vec<u8> coerces to &[u8].
    // The compiler extracts the pointer and length from the Vec.
    let v_count = analyze(&vec_data);

    // [u8; 3] coerces to &[u8].
    // The compiler treats the array as a slice of its contents.
    let a_count = analyze(&arr_data);

    println!("Vec count: {}, Array count: {}", v_count, a_count);
}

This coercion is why function signatures take slices instead of vectors. If you write fn process(data: &Vec<u8>), callers must pass a vector. They cannot pass an array. They cannot pass a slice of a string. If you write fn process(data: &[u8]), callers can pass anything that contains bytes. The API becomes more general and easier to use.

Convention aside: always accept &[T] or &str in function parameters unless you specifically need to mutate the collection or store it. Accepting slices makes your functions usable with arrays, vectors, and substrings without extra work from the caller.

Write functions that take slices. Let callers pass whatever they have.

Real-world pattern: zero-copy parsing

Slices shine when you parse data. You can extract fields from a buffer without allocating new strings or vectors. The result is a slice pointing back into the original buffer. This pattern is called zero-copy parsing.

/// Extracts the username from an email address.
/// Returns a slice of the input, avoiding allocation.
fn extract_username(email: &str) -> Option<&str> {
    // Find the '@' symbol.
    // `find` returns the byte index of the character.
    if let Some(at_pos) = email.find('@') {
        // Return a slice from the start up to '@'.
        // The lifetime of the result is tied to `email`.
        Some(&email[..at_pos])
    } else {
        // No '@' found. Return None.
        None
    }
}

fn main() {
    let address = "user@example.com";

    // Extract username without copying.
    if let Some(user) = extract_username(address) {
        println!("Username: {}", user);
    }
}

The function returns Option<&str>. The &str is a slice of the input. The caller receives a view into the original string. No memory is allocated for the username. The cost is just finding the index and creating the slice pointer.

This pattern works for binary data too. You can parse a network packet by slicing the header and payload. The parser returns slices to the header fields and the payload. The network stack holds the buffer. The parser just describes where the data lives.

Return slices to avoid allocations. Keep the data where it belongs.

Pitfalls: indexing, mutability, and UTF-8

Slices have traps. Indexing a slice panics if the index is out of bounds. The compiler cannot always prove the index is valid. In production code, use get instead of indexing. get returns Option<&T>. It lets you handle missing data gracefully.

fn main() {
    let data = [10, 20, 30];

    // Indexing panics if index is out of bounds.
    // let bad = data[5]; // PANIC

    // `get` returns None if index is out of bounds.
    // This is safe and idiomatic for uncertain indices.
    if let Some(value) = data.get(1) {
        println!("Found: {}", value);
    } else {
        println!("Index out of range");
    }
}

Convention aside: use slice.first() and slice.last() instead of slice.get(0) or slice.get(slice.len() - 1). These methods are clearer and avoid arithmetic. They return Option<&T>.

Slices of strings have an extra pitfall. Strings in Rust are UTF-8 encoded. Characters can be one to four bytes long. You cannot slice a string at an arbitrary byte index. The slice must start and end on character boundaries. If you slice in the middle of a multi-byte character, the program panics.

fn main() {
    let text = "café";

    // 'é' is two bytes.
    // Index 3 cuts 'é' in half.
    // This panics at runtime with a "byte index is not a char boundary" error.
    // let bad = &text[0..3];

    // Use `char_indices` or `split_at` to find safe boundaries.
    // Or use `get` which returns None on invalid boundaries.
    if let Some(valid) = text.get(0..3) {
        println!("{:?}", valid);
    } else {
        println!("Invalid boundary");
    }
}

The compiler cannot check UTF-8 boundaries at compile time. The check happens at runtime. Use get or helper methods to avoid panics.

Indexing panics. get returns Option. Choose safety over brevity.

When to use slices vs owned types

Choosing between slices and owned types depends on ownership and mutation. Use slices when you need a view. Use owned types when you need to store or modify the data.

Use &[T] when you pass a subset of data to a function and the caller keeps ownership. Use &[T] when you parse data and want to return references without allocation. Use &[T] when you need a generic sequence that accepts arrays, vectors, and other collections via coercion.

Use Vec<T> when you need to own the data and control its lifetime. Use Vec<T> when the function must extend the collection or store it beyond the current scope. Use Vec<T> when you need to mutate the length of the sequence.

Use &str when dealing with text parameters. &str is the slice type for strings. It accepts both String and string literals. Use &str for function arguments to maximize flexibility.

Use String when you must mutate the text or own the result. Use String when the data must outlive the source buffer. Use String when you build text incrementally.

Pass slices. Own vectors. Let the compiler coerce.

Where to go next