The first wall: returning a piece of a string
You write a function to extract the first word from a sentence. You pass in a String, the function finds the space, and you try to hand back just that first word. The compiler immediately rejects you. It refuses to let you return a piece of the string without taking ownership of the whole thing. This is the first real wall most Rust learners hit.
Borrowing vs owning: the address label
Rust separates ownership from access. When a function takes a String, it expects to own the entire heap allocation. Returning a slice of it would leave the original owner holding a reference to data that might be dropped. Borrowing solves this by returning a view instead of a copy. Think of it like handing someone a sticky note with an address written on it. You are not giving them the house. You are giving them a pointer to where the house lives. The pointer is only valid as long as the house stands. If the original owner tears the house down, the sticky note becomes useless. The compiler enforces this rule at compile time. It tracks how long the original data lives and guarantees that any returned reference cannot outlive it.
Trust the borrow checker. It usually has a point.
Minimal example
The fix is straightforward. Change the function signature to accept a string slice and return a string slice. A slice is a borrowed view into existing data. It carries no ownership and triggers no allocation.
/// Returns a slice pointing to the first word in the input string.
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes(); // Convert to bytes for fast iteration
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i]; // Return a slice up to the space
}
}
&s[..] // No space found, return the whole string
}
fn main() {
let my_string = String::from("hello world");
let word = first_word(&my_string); // Pass a reference, get a reference back
println!("The first word is {}", word);
}
The function signature fn first_word(s: &str) -> &str tells the compiler that the output borrows from the input. The caller keeps ownership of my_string. The function merely calculates an index and hands back a pointer plus a length. Zero allocations happen. Zero bytes are copied.
Let the compiler handle the lifetime math.
What happens under the hood
When the compiler sees a function with one input reference and one output reference, it applies lifetime elision. It automatically ties the output lifetime to the input lifetime. The returned &str is guaranteed to live no longer than s. This rule exists so you do not have to write explicit lifetime annotations for simple cases.
At runtime, a &str is exactly two machine words: a pointer to the start of the slice and its length in bytes. The original String holds three words: a pointer, a length, and a capacity. When you call first_word, the function reads the bytes, finds the space at index five, and returns a new two-word struct pointing at the same heap buffer. The caller immediately reads from that buffer. The borrow checker does all the heavy lifting before the program even runs.
If you come from Python or JavaScript, this feels different because those languages return new string objects by default. Every substring operation allocates fresh memory. Rust slices avoid that cost entirely. They are lightweight views that the garbage collector never touches because they are tied to stack lifetimes.
Zero allocations, zero copies. Keep your functions thin.
Realistic example: parsing config lines
Real code rarely extracts a single word. It usually parses structured text and returns multiple views. Consider a configuration loader that reads lines like database_host = localhost. You want to extract the key without copying the line.
/// Extracts the configuration key from a "key=value" line.
/// Returns the key as a slice of the original input.
fn extract_key(line: &str) -> Option<&str> {
let trimmed = line.trim(); // Trim whitespace without allocating
if let Some(pos) = trimmed.find('=') {
Some(&trimmed[0..pos]) // Return slice pointing into trimmed data
} else {
None // No equals sign found
}
}
fn main() {
let config_line = " database_host = localhost ";
if let Some(key) = extract_key(config_line) {
println!("Found key: {}", key);
}
}
The trim() method returns a &str that borrows from line. The slice &trimmed[0..pos] borrows from trimmed, which transitively borrows from line. The compiler chains these lifetimes together automatically. The returned Option<&str> carries the same lifetime as the input. You can store the slice alongside the original line, pass it to other functions, or print it. As long as config_line stays in scope, the slice remains valid.
The community prefers &str over &String in function signatures. &String forces the caller to allocate a String first, then borrow it. &str accepts both &String and string literals directly. It is the idiomatic way to accept read-only text. When slicing, prefer &s[..i] over s.get(start..end).unwrap(). The slice syntax panics on invalid bounds, which is usually what you want in internal logic, while get returns Option and forces extra branching.
Pick the return type that matches the data's origin. The rest follows naturally.
Common pitfalls and compiler rejections
The most frequent mistake is trying to return a reference to data created inside the function.
fn bad_example() -> &str {
let local = String::from("temporary");
&local // Compiler rejects this immediately
}
The compiler rejects this with E0515 (cannot return reference to local variable). The local string lives on the stack frame of bad_example. When the function returns, that stack frame is destroyed. Handing back a reference would create a dangling pointer. Rust refuses to compile it. If you need to return newly created text, return an owned String instead.
Another trap appears when you mix borrowed and owned data in the same return path. Suppose your function returns a slice when the input is valid, but allocates a fallback string when it is not. You cannot return &str alone. The compiler will reject the mismatched types. You must return String to force allocation on both paths, or use Cow<str> to keep the fast path zero-cost while allowing allocation on the slow path.
A third issue surfaces when you try to mutate the original data while holding a returned slice. The borrow checker enforces exclusive access. If you store a slice and then try to push to the original String, you get E0502 (cannot borrow as mutable because it is also borrowed as immutable). The compiler prevents use-after-free bugs by locking the data behind the slice. Drop the slice before mutating, or clone the data if you need independent copies.
If the data is born inside the function, it must leave as an owned value. Never try to smuggle a local reference out the door.
Choosing your return type
Use &str when the data already exists in the caller's scope and you only need to point to a portion of it. Use String when the function creates new data, modifies the input in place, or needs to own the result for later mutation. Use Cow<str> when your function might return a borrowed slice in the fast path but occasionally needs to allocate a new string in the slow path. Reach for &[u8] when you are processing binary data or need byte-level indexing without UTF-8 validation overhead.
Counter-intuitive but true: the more you allocate, the harder the rest of your code becomes to reason about. Prefer slices until profiling proves otherwise.