Skip to content

Borrowing, Slices, and Lifetime Reasoning

Ownership answers who releases a value. Borrowing answers who may temporarily access it without becoming an owner.

AI systems benefit from borrowed views: a transcript segment can point into one source document, an audio window can point into a sample buffer, and a parser can inspect a prompt without copying it. The hard part is not punctuation. It is proving that the referent stays alive and that mutation cannot invalidate someone else’s view.

After this chapter, you will be able to:

  • state Rust’s shared- and mutable-reference rules;
  • use &str, &[T], and &mut [T] as bounded views rather than owned containers;
  • derive when lifetime elision is sufficient and when a named lifetime is required;
  • read a lifetime parameter as a relationship, not a duration;
  • design zero-copy parsers that cannot outlive their source;
  • recognize self-referential and async-lifetime traps;
  • decide where to convert a borrowed representation into owned data;
  • debug common borrow-checker failures by repairing ownership boundaries; and
  • run the borrowed transcript and sample-window example.

You should understand ownership, moves, structs, Result, and pattern matching. This chapter is a prerequisite for strings/bytes, iterator adapters, trait APIs, async spawning, streaming media, and zero-copy request parsing.

Owned source String
├── &str line 0..20 ─▶ TranscriptSegment<'source>
├── &str line 21..54 ─▶ TranscriptSegment<'source>
└── dropped only after every segment is no longer used

The compiler does not keep the source alive with reference counting. It rejects any program where a borrow might be used after the source is dropped.

&T is a shared reference. It permits reading T while the borrow is active.

&mut T is an exclusive reference. It permits reading and mutation while excluding all other access to that value.

The core rules are:

  1. any number of shared references, or
  2. exactly one mutable reference,
  3. but not both at the same time;
  4. every reference must remain valid for every use.

These rules prevent iterator invalidation and data races in safe Rust.

fn byte_len(prompt: &str) -> usize {
prompt.len()
}
let prompt = String::from("evidence");
let length = byte_len(&prompt);
println!("{prompt}: {length}");

byte_len receives a temporary view. The caller still owns prompt and can use it afterward.

Passing a mutable reference does not necessarily move it forever. Rust can create a shorter reborrow:

fn normalize(samples: &mut [f32]) {
for sample in samples {
*sample = sample.clamp(-1.0, 1.0);
}
}
let mut samples = vec![0.0, 1.4];
normalize(&mut samples);
println!("{samples:?}");

The exclusive borrow ends after its last use, so the vector becomes available again.

&[T] is a shared view over a contiguous sequence. It contains a pointer and a length. &mut [T] is the exclusive version.

fn energy(samples: &[f32]) -> f32 {
samples.iter().map(|sample| sample * sample).sum()
}

The caller can pass an array, vector, or subslice. This makes the function more reusable than accepting &Vec<f32>.

&str is a UTF-8 string slice. Its boundaries must fall on UTF-8 code point boundaries. Byte indices are not character indices; the next chapter treats this carefully.

Indexing panics on an invalid range:

let window = &samples[start..end];

When offsets come from a model, user, file, or external service, use checked access:

fn sample_window(
samples: &[f32],
range: std::ops::Range<usize>,
) -> Result<&[f32], WindowError> {
samples.get(range).ok_or(WindowError::OutOfBounds)
}

The returned slice borrows from samples; it allocates nothing.

A lifetime parameter does not specify seconds, scopes, or storage classes. It relates references.

fn sample_window<'a>(
samples: &'a [f32],
range: Range<usize>,
) -> Result<&'a [f32], WindowError>

Read this as:

For some lifetime 'a, the function receives a sample slice valid for 'a and may return a subslice valid for no longer than that same 'a.

The function cannot conjure a longer-lived reference. The caller chooses the concrete lifetime through its usage.

Rust lets the explicit 'a above be omitted:

fn sample_window(
samples: &[f32],
range: Range<usize>,
) -> Result<&[f32], WindowError>

The elision rules can unambiguously tie the sole borrowed output to the sole borrowed input.

Named lifetimes are required when multiple sources could supply the output:

fn longer_text<'a>(left: &'a str, right: &'a str) -> &'a str {
if left.len() >= right.len() { left } else { right }
}

Both inputs use 'a, so the returned reference is usable only for the overlap of their valid lifetimes. This signature does not assert that both strings were originally allocated together or live for exactly the same duration.

A struct holding references must state their relationship:

struct TranscriptSegment<'source> {
start_ms: u64,
end_ms: u64,
text: &'source str,
}

Every TranscriptSegment<'source> is valid only while its source text remains valid. A parser can therefore avoid allocating one String per segment:

fn parse_transcript(
source: &str,
) -> Result<Vec<TranscriptSegment<'_>>, TranscriptError>

The vector owns its segment structs but the segments do not own their text. Dropping the vector is cheap; dropping the source before using the vector is forbidden.

  • synchronous parsing and validation;
  • temporary views over memory-mapped data;
  • local prompt assembly from one stable arena;
  • bounded media windows used immediately;
  • request-scoped computation.
  • job queues and durable state;
  • spawned tasks;
  • database and object-storage records;
  • caches with independent eviction;
  • values returned across process or FFI boundaries;
  • APIs where lifetime parameters would infect unrelated layers.

Zero-copy is a tradeoff. It couples the representation’s lifetime to the source.

The dangling-reference failure Rust prevents

Section titled “The dangling-reference failure Rust prevents”

This function cannot compile:

fn broken() -> &str {
let prompt = String::from("temporary");
&prompt
}

prompt is dropped when broken returns. Returning a reference would point at freed memory. The correct solution depends on intent:

fn owned() -> String {
String::from("durable")
}
fn borrowed(input: &str) -> &str {
input.trim()
}

Do not “solve” this by adding 'static or leaking memory. Return ownership when the function creates the durable value; return a borrow when it is a view of caller-owned data.

&'static str is a reference valid for the entire program, such as a string literal. A type bound T: 'static means T contains no non-static borrowed references; it does not mean the value itself must remain allocated forever.

Spawned tasks often require owned values satisfying 'static because the task may outlive the calling stack:

let task_id = task_id.to_owned();
tokio::spawn(async move {
process(task_id).await
});

The String can still be dropped when the task ends. The bound guarantees it does not secretly borrow from a shorter stack frame.

Prefer structured concurrency when a child should not outlive its parent. Ownership requirements should reflect real task lifetime, not merely satisfy an API.

Suppose a view points into a vector. Pushing may reallocate the vector, moving all elements. Rust prevents mutation while the view is later used:

let mut tokens = vec!["a", "b"];
let first = &tokens[0];
// tokens.push("c"); // rejected while `first` will be used
println!("{first}");

This is the same class of bug as an invalidated iterator in languages with unchecked aliases.

Non-lexical lifetimes let the compiler end the borrow after the last use, not necessarily at the end of the surrounding braces:

let first = &tokens[0];
println!("{first}"); // last use
tokens.push("c"); // allowed

Rust must prove that two mutable references do not overlap. Standard APIs encode that proof:

let (left, right) = samples.split_at_mut(midpoint);
normalize(left);
normalize(right);

Do not create two mutable slices with raw pointers merely because the borrow checker cannot infer your arithmetic. Use safe splitting methods or isolate and thoroughly justify a small unsafe abstraction later in the FFI chapter.

For parallel media processing, disjoint chunk APIs can provide independent mutable regions. Still bound task count and chunk size; alias safety is not backpressure.

std::borrow::Cow<'a, T> can hold either borrowed data or an owned replacement. It is useful when most inputs pass through unchanged but some require normalization:

use std::borrow::Cow;
fn normalize_newlines(input: &str) -> Cow<'_, str> {
if input.contains("\r\n") {
Cow::Owned(input.replace("\r\n", "\n"))
} else {
Cow::Borrowed(input)
}
}

Do not use Cow automatically. It adds an enum branch and lifetime parameter. A plain owned result may be clearer when normalization usually allocates or crosses a durable boundary.

Self-referential structs are a warning sign

Section titled “Self-referential structs are a warning sign”

This design tries to own a string and store slices into itself:

struct Document {
source: String,
sections: Vec<&str>, // where would this lifetime point?
}

Moving Document can move the String descriptor, and construction requires references into a value that is not fully built. Safe Rust intentionally makes ordinary self-referential structs hard.

Prefer one of these:

  • store byte ranges and derive slices when needed;
  • store owned section strings;
  • keep the source in one owner and a separate borrowed view object;
  • use stable shared storage plus validated offsets;
  • use a reviewed pinning/arena crate only when the measured need justifies it.

For traces and evidence, source ID plus offsets is often more durable than a runtime pointer.

The complete example is crates/mosaic-domain/examples/borrowing_and_lifetimes.rs.

Terminal window
cd rust/rust-ai-engineering
cargo run -q -p mosaic-domain --example borrowing_and_lifetimes
cargo test --workspace --examples

Expected output:

2 borrowed segments; first: deploy started; window samples: 2

It performs two zero-copy operations:

  1. parses timed transcript lines into structs whose text fields borrow from one source;
  2. returns a checked audio sample window borrowing from an array.

The parser owns numeric timestamps because parsing creates values. It borrows text because each caption already exists as a contiguous slice of the source. Its test proves a reversed interval is a typed error.

Try uncommenting a use of segments after dropping source. The compiler should reject the program. That failed compile is evidence of the lifetime contract.

Failure investigation: a spawned task “needs 'static

Section titled “Failure investigation: a spawned task “needs 'static””

Symptom: tokio::spawn rejects a future because a transcript borrows the request body.

Bad reaction: add 'static annotations everywhere, leak the body, or wrap a reference in Arc. Arc<&str> still shares a reference; it does not extend the referent’s lifetime.

Investigation:

  1. identify which value is borrowed;
  2. identify the actual owner;
  3. decide whether the child may outlive the request;
  4. if yes, move an owned representation or Arc<str> into the task;
  5. if no, keep the work within structured parent scope;
  6. measure whether selective ownership is preferable to cloning the full body.

Possible fix:

let owned_segments = borrowed_segments
.into_iter()
.map(|segment| OwnedSegment {
start_ms: segment.start_ms,
end_ms: segment.end_ms,
text: segment.text.to_owned(),
})
.collect::<Vec<_>>();

This allocation is not defeat. It records that the queued work must survive independently.

Store offsets or borrowed slices during local processing. At a durable boundary, clone only the selected text needed by the job.

If a function creates the data, return an owned value. A lifetime annotation cannot keep a local allocation alive.

Giving every reference the same 'a can claim relationships that do not exist and make borrows last longer than necessary. Use elision or distinct lifetime parameters when outputs are tied to only one input.

Prefer &str and &[T] unless the function specifically needs container-only behavior.

Model-generated timestamp and byte ranges must use checked access and semantic validation.

Borrow-checker friction is design feedback. Repair ownership first. Reserve unsafe for small, audited boundaries whose invariants are documented and tested.

Borrowing can reduce allocation and copying, but it can also retain an entire backing allocation. A ten-byte slice into a 2 GiB buffer keeps that buffer useful—and therefore prevents its owner from being dropped while the slice is used. Copy a small selected region when early release saves more memory than zero-copy saves.

Validate before slicing:

  • range ordering;
  • arithmetic overflow when converting time to samples;
  • source length;
  • UTF-8 boundaries for str;
  • media plane strides and dimensions;
  • alignment expected by native code.

Do not let a borrowed view escape the authorization scope of its owner. Lifetimes prove memory validity, not access control. A valid &[u8] can still contain data the caller must not see.

Avoid holding mutable borrows or synchronization guards across .await; it can block unrelated work and complicate cancellation. Extract or own the needed value before suspension when possible.

  1. State the shared-versus-exclusive reference rule.
  2. What relationship does a lifetime parameter express?
  3. Why can &[T] accept more callers than &Vec<T>?
  4. What does T: 'static mean?
  1. Extend the transcript parser to reject empty text without allocating.
  2. Add a function returning every segment overlapping a requested time range as borrowed entries.
  3. Implement a checked mutable audio window and clamp only that region.
  4. Replace borrowed text with byte ranges and add an accessor that derives &str from the source.
  5. Add Cow<'_, str> normalization for Windows newlines and prove the clean path does not allocate.

Choose borrowed or owned representations for:

  • a request-scoped JSON field;
  • a job scheduled for tomorrow;
  • a memory-mapped model vocabulary;
  • an SSE event buffered for reconnect;
  • one frame passed synchronously to OCR;
  • a trace record stored for 30 days.

Explain the owner, maximum lifetime, invalidation risk, and expected copy cost.

Create minimal examples for:

  1. returning a reference to a local String;
  2. pushing to a vector while an element reference remains in use;
  3. spawning a future that captures a borrowed request;
  4. attempting two overlapping mutable slices.

Record the compiler message, the violated ownership fact, and the semantic fix. Do not merely add clones until the compiler stops.

The overlap function can return an iterator or Vec<&TranscriptSegment<'_>>; its outputs borrow the parsed segment collection, whose text in turn borrows the source. Write the simplest correct lifetimes first and let elision work.

For range-backed segments, validate byte boundaries during parsing and store Range<usize>. An accessor can call source.get(range.clone()) and return a typed error if invariants were somehow broken.

The Cow clean path should match Cow::Borrowed(_); the conversion path should match Cow::Owned(_). Do not compare performance using only debug builds.

For spawned work, either make the child structurally unable to outlive the request or convert the minimum required data to owned values before spawning.

  • Why does Arc<&str> not turn a request borrow into owned text?
  • Can two shared references exist while one mutable reference exists?
  • When does non-lexical lifetime analysis end a borrow?
  • Why is storing ranges often easier than a self-referential struct?
  • What can a lifetime prove that authorization cannot, and vice versa?
  • When can copying a small slice reduce peak memory?
  • Why should model-produced ranges use get instead of indexing?
  • I can draw the owner and every borrow in a small data flow.
  • I can explain a named lifetime in one sentence without saying it extends a value.
  • I use slices rather than references to concrete containers where appropriate.
  • I return owned values created inside a function.
  • I convert to ownership at task, queue, process, and persistence boundaries.
  • I avoid self-referential storage by using ranges or separate view objects.
  • I validate untrusted ranges before access.
  • I ran the companion example and its failure test.