How to fix Rust E0515 cannot return reference to local variable

Rust's E0515 stops you from returning a reference to a local variable, which would dangle. The fix depends on intent: return ownership, return a reference tied to an input, or fill a caller-provided buffer.

The error that stops every new Rust programmer

You write a function that formats a user's name. It builds a new string, grabs a reference to it, and hands it back. The code reads perfectly. The compiler stops you dead with E0515: cannot return reference to local variable. It feels like the compiler is missing the point. You just want to return the text.

Why the compiler refuses to hand out expired keys

Rust separates ownership from borrowing. When a function creates a value, that function owns it. Returning a reference is like handing someone a key to a storage unit you just locked and demolished. The key still turns mechanically, but the contents are gone. Rust refuses to hand out keys to demolished storage units. The error exists because the local variable will be destroyed the moment the function finishes. Any reference to it becomes a dangling pointer. In C or C++, that dangling pointer might work for ten minutes until the allocator reuses the memory, then your program crashes or leaks secrets. Rust catches the demolition before the keys are printed.

The smallest case that triggers E0515

Here is the minimal reproduction. It compiles in Python or JavaScript without complaint. Rust rejects it immediately.

fn build_tag() -> &str {
    let tag = String::from("urgent");
    &tag
}

The compiler rejects this with E0515. The tag variable lives on the stack frame of build_tag. When the function returns, the stack frame is wiped. The &tag reference points to wiped memory. Rust's lifetime system tracks exactly how long every reference is allowed to exist. A reference to a local can never outlive the local itself. Trust the borrow checker here. It is preventing a memory safety violation that would otherwise require runtime checks or manual debugging.

How lifetime elision accelerates the failure

There is a hidden rule making this fail even faster: lifetime elision. When you write fn build_tag() -> &str without any input parameters, the compiler assumes the returned reference must live forever. That lifetime is called 'static. It means the data must survive for the entire duration of the program. A String created inside a function clearly does not survive forever. The compiler spots the mismatch between the implied 'static lifetime and the actual temporary lifetime of tag, and emits E0515 before it even finishes checking the borrow rules.

Lifetime elision exists to keep signatures readable. The compiler fills in missing lifetime parameters using three simple rules. The first rule applies when there are no input references. The compiler assumes the output reference must be 'static. The second rule applies when there is exactly one input reference. The compiler ties the output lifetime to that input. The third rule applies when multiple inputs exist. The compiler refuses to guess and forces you to write explicit lifetimes. E0515 usually hits you on the first rule. You are asking for a 'static reference but only providing a temporary value.

Fix one: return ownership

If the function creates the data, the caller should own it. Change the return type to the owned type. The heap allocation travels with the value.

// Return an owned String so the caller controls the memory.
fn build_tag() -> String {
    String::from("urgent")
}

fn main() {
    // `tag` now owns the heap allocation.
    let tag = build_tag();
    println!("Processing: {tag}");
}

This is the default choice. The performance cost is a single heap allocation, which is exactly what you would have paid in any other language. The caller gets full control and can store it, pass it around, or drop it whenever they want. Convention note: when you see a function returning String or Vec<T>, read it as "this function creates new data." The caller is responsible for cleanup.

Fix two: tie the reference to an input

If you are extracting or slicing data that already exists, return a reference whose lifetime matches the input. The output cannot outlive the source.

// The output lifetime is tied to the input lifetime.
fn extract_domain(email: &str) -> &str {
    match email.find('@') {
        Some(pos) => &email[pos + 1..],
        None => email,
    }
}

fn main() {
    let address = String::from("user@example.com");
    let domain = extract_domain(&address);
    println!("Domain: {domain}");
}

Lifetime elision handles the signature automatically. Because there is exactly one input reference, the compiler assumes the output reference shares its lifetime. The full signature would be fn extract_domain<'a>(email: &'a str) -> &'a str. You rarely need to write the explicit lifetime here. The reference is valid as long as address is alive. No allocation happens. The slice points directly into the original String buffer. Use this pattern whenever you are parsing, filtering, or viewing existing data. The borrow checker guarantees the slice never dangles.

Fix three: fill a caller-provided buffer

Sometimes you are writing performance-critical code and want to avoid allocations entirely. Pass the storage in as a mutable parameter and return a reference into it.

// Reuse the caller's buffer to avoid repeated heap allocations.
fn format_header(buf: &mut String, key: &str) -> &str {
    buf.clear();
    buf.push_str("X-");
    buf.push_str(key);
    buf.as_str()
}

fn main() {
    let mut buffer = String::with_capacity(64);
    let header = format_header(&mut buffer, "Cache-Control");
    println!("{header}");
}

The caller owns buffer. The function mutates it and hands back a slice. The returned reference is tied to buffer lifetime. This pattern appears constantly in network parsers and game loops where you process thousands of messages per second. Convention note: always call buf.clear() before reusing a buffer. It drops the old contents safely and resets the length without freeing the underlying capacity. Pre-allocating with with_capacity avoids early reallocations when the buffer grows.

The static trap and the closure variant

Newcomers often try to bypass the error by adding 'static to the return type.

// This still fails. The local String is not 'static.
fn build_tag_static() -> &'static str {
    let tag = String::from("urgent");
    &tag
}

Adding 'static does not magically extend the lifetime. It only tells the compiler you promise the data lives forever. The borrow checker verifies that promise. If the data is a local variable, the promise is false, and the code refuses to compile. The only things that are genuinely 'static are string literals baked into the binary, or data intentionally leaked with Box::leak. Leaking memory to satisfy the compiler is a real memory leak. Do not do it.

Closures introduce a subtle variant of this problem. A closure that captures a local variable by reference inherits the same lifetime rules.

// Fails: closure borrows `s`, but `s` is dropped when the function returns.
fn make_logger() -> impl Fn() {
    let s = String::from("debug");
    || println!("{s}")
}

The closure tries to borrow s. The function returns the closure. s dies at the closing brace. The closure outlives its data. The fix is to transfer ownership into the closure using move.

// `move` forces the closure to take ownership of captured variables.
fn make_logger() -> impl Fn() {
    let s = String::from("debug");
    move || println!("{s}")
}

The move keyword changes the capture semantics. Instead of borrowing s, the closure steals it. The closure becomes self-contained. This is the closure equivalent of returning ownership. Convention aside: when you see move || in Rust code, read it as "this closure owns everything it uses." It is the standard way to hand data to async tasks or threads. The compiler will emit E0373 if you forget move and try to return a capturing closure. Treat that error as a reminder to check ownership boundaries.

Decision matrix

Use an owned return type like String or Vec<T> when the function creates new data that needs to survive past the call. Use a borrowed return type like &str or &[T] when the function extracts or slices data from an input parameter. Use a caller-provided buffer with &mut T when you are in a tight loop and want to amortize allocations across thousands of calls. Use move on closures when the closure must outlive the function that creates it. Reach for &'static str only when returning a compile-time constant literal.

The borrow checker is not blocking your progress. It is forcing you to decide who owns the memory. Make that decision explicit in the signature.

Where to go next