Mehdi Akiki
Rust Failure Atlas / Language and diagnostics

RFA-054 · Case file with fixtures · Case 26 of 694 · Compiler evidence

Why `'static` Cannot Make Rust Return a Reference to a Local String

Lifetime annotations describe how references relate; they do not keep local storage alive. Return ownership, borrow from an input, or use genuinely static data according to where the bytes belong.

Reviewed
Rust
Rust 1.98.1
Targets
all targets
Profiles
dev, release, test

Direct answer

What this Rust failure means

Why it happens
A lifetime annotation describes a relationship but does not extend storage; the local String is dropped before any returned reference could be used.
First discriminating check
Change the return type to owned `String` without changing how the text is built and confirm that moving ownership across the boundary compiles.

Adding 'static can look like a request to keep a value forever. It is not. This program still fails:

fn label(id: u64) -> &'static str {
    let rendered = format!("item-{id}");
    rendered.as_str()
}

Rust 1.98.1 reports:

error[E0515]: cannot return value referencing local variable `rendered`

The error is about storage, not missing syntax. The failing fixture creates a String inside label. That String owns a heap allocation and is dropped when the function returns. A slice pointing into that allocation cannot be used by the caller afterward.

A lifetime is a checked relationship

The Rust book's lifetime chapter makes the core point: annotations do not change how long references live. They describe relationships which the compiler must verify.

In the failing signature, &'static str promises that the returned reference is valid for the remainder of the program. Nothing inside the function can satisfy that promise. The local owner disappears at the return boundary:

enter label
  create rendered String
  borrow its bytes as &str
  attempt to return reference
drop rendered String
leave label
  caller would receive a dangling reference

Rust refuses the final two steps. Writing a longer lifetime makes the impossible promise larger; it does not extend the allocation.

Return the ownership that was created

The direct repair is:

fn label(id: u64) -> String {
    format!("item-{id}")
}

The String moves to the caller. Its allocation remains alive for as long as the caller owns it. Modern Rust moves the small string handle—pointer, length, and capacity—without copying all formatted bytes.

The repaired fixture compiles, runs, and checks the returned text. This is the correct model when each call constructs new data.

A borrowed return needs an input owner

Sometimes the function does not create text. It chooses a view into text supplied by the caller:

fn before_colon(input: &str) -> &str {
    input.split_once(':').map_or(input, |(left, _)| left)
}

Lifetime elision connects the output reference to input. The caller owns or otherwise keeps the input bytes alive. The function only selects a region inside them. An explicit version would be fn before_colon<'a>(input: &'a str) -> &'a str.

This works because there is an owner outside the function. In the original label, there is no input containing item-7; the bytes are born locally, so they need to leave as an owned value.

When &'static str is genuine

A string literal is stored in the program image and can provide a static reference:

fn status(ok: bool) -> &'static str {
    if ok { "ready" } else { "failed" }
}

Both possible byte sequences already exist for the duration of the program. No local String owns them. This is a real static source, rather than a local value with a static annotation added.

Leaking a String with Box::leak can also manufacture a static reference, but it deliberately prevents the allocation from ever being reclaimed. That is appropriate only for bounded, process-lifetime data such as one-time configuration interned under a controlled policy. Using it for request labels turns every call into a memory leak.

Cow is useful only when both cases exist

An API which sometimes returns a literal and sometimes formats owned data can use Cow<'static, str>:

use std::borrow::Cow;

fn label(id: Option<u64>) -> Cow<'static, str> {
    match id {
        None => Cow::Borrowed("unknown"),
        Some(id) => Cow::Owned(format!("item-{id}")),
    }
}

This avoids allocation for the literal case while preserving ownership for dynamic text. It is not automatically better than String; it adds an enum branch to every consumer. I use it when measurements or API semantics show that the borrowed case matters.

Questions that resolve E0515 quickly

I ask where the returned bytes are owned:

  • Created in this call: return String or another owned type.
  • Stored in an input: return a reference tied to that input.
  • Compiled into the program: a static reference may be correct.
  • Stored in a longer-lived object: borrow from that object and expose its lifetime.

The official E0515 page shows the same boundary for references and iterators. The reusable lesson is that return types cannot outlive their backing storage. Once the owner is named, the correct API usually becomes obvious without experimenting with arbitrary lifetime annotations.