Skip to content

Rust Lifetimes and Borrowed APIs

Rust lifetime annotations describe relationships between references; they do not keep local variables alive or delay their destruction. This lesson selects between borrowed Raspberry Pi labels and stores a borrowed view, then checks exactly which storage must remain valid.

Prerequisites and outcome

Complete traits and associated types and review references and slices. You will distinguish the lifetime of backing data from a borrow, write an explicit relationship for two inputs, and recognise when elision is enough. The fixtures require no hardware access or external crates.

The Rust lifetime relationship we need

Selecting either of two string slices needs a return contract that covers both possible sources. In longer_label<'a>(left: &'a str, right: &'a str) -> &'a str, callers must supply references valid for a common region 'a, and the returned reference is usable within that region.

This does not require the two String owners to be created or dropped at the same time. A longer-lived shared reference can be used for a shorter common region. The function's signature does not promise that a particular runtime comparison will select the longer-lived owner's data. See the Book's lifetime relationships.

Build the complete borrowed-label program

cargo new pi_lifetimes
cd pi_lifetimes

Keep edition = "2024" in Cargo.toml and replace src/main.rs with:

// "Longer" means UTF-8 byte length here, not characters or storage lifetime.
fn longer_label<'a>(left: &'a str, right: &'a str) -> &'a str {
    if left.len() >= right.len() {
        left
    } else {
        right
    }
}

// Only the first input can be returned: do not tie it to the second input.
fn first_label<'a>(left: &'a str, _right: &str) -> &'a str {
    left
}

fn byte_length(label: &str) -> usize {
    label.len()
}

struct DeviceView<'a> {
    label: &'a str,
}

impl<'a> DeviceView<'a> {
    fn new(label: &'a str) -> Self {
        Self { label }
    }

    // Copy the shared reference to the backing label, not to the view.
    fn label(&self) -> &'a str {
        self.label
    }
}

fn fixture_label() -> &'static str {
    "fixture"
}

fn main() {
    let primary = String::from("Pi 4B");
    let alternative = String::from("Pi 5");
    let selected = longer_label(&primary, &alternative);
    println!("selected={selected}, bytes={}", byte_length(selected));

    let retained;
    {
        let view = DeviceView::new(&primary);
        retained = view.label();
    } // The view ends, but primary still owns the referenced bytes.
    println!("retained={retained}");

    let independent;
    {
        let temporary = String::from("temporary alternative");
        independent = first_label(&primary, &temporary);
    }
    println!("independent={independent}");
    println!("static={}", fixture_label());
}

#[cfg(test)]
mod tests {
    use super::{DeviceView, byte_length, first_label, fixture_label, longer_label};

    #[test]
    fn selection_can_return_either_input() {
        assert_eq!(longer_label("Pi 4B", "Pi 5"), "Pi 4B");
        assert_eq!(longer_label("Pi", "Pi Zero 2 W"), "Pi Zero 2 W");
    }

    #[test]
    fn empty_labels_are_valid() {
        assert_eq!(longer_label("", ""), "");
        assert_eq!(longer_label("", "Pi"), "Pi");
    }

    #[test]
    fn equal_byte_lengths_choose_the_left_input() {
        let left = String::from("AB");
        let right = String::from("CD");
        assert!(std::ptr::eq(longer_label(&left, &right), left.as_str()));
    }

    #[test]
    fn unicode_selection_uses_bytes_not_characters() {
        assert_eq!(byte_length("π"), 2);
        assert_eq!(longer_label("π", "a"), "π");
    }

    #[test]
    fn view_may_end_before_the_borrowed_label() {
        let owner = String::from("Pi 4B");
        let saved;
        {
            let view = DeviceView::new(&owner);
            saved = view.label();
        }
        assert_eq!(saved, "Pi 4B");
    }

    #[test]
    fn first_input_does_not_borrow_the_second_after_return() {
        let owner = String::from("Pi 4B");
        let saved;
        {
            let other = String::from("Pi Zero 2 W");
            saved = first_label(&owner, &other);
        }
        assert_eq!(saved, "Pi 4B");
    }

    #[test]
    fn literal_can_supply_a_static_reference() {
        let value: &'static str = fixture_label();
        assert_eq!(value, "fixture");
    }
}
1
2
3
4
cargo check
cargo test
cargo fmt --check
cargo run --quiet

Expected output:

1
2
3
4
selected=Pi 4B, bytes=5
retained=Pi 4B
independent=Pi 4B
static=fixture

There are seven tests. String and str length is measured in bytes; selecting a label by byte length is a fixture rule, not Unicode display-width logic. See String length. The returned references do not clone the label text.

Signature relationships are more precise than adding apostrophes

The generic parameter is declared with <'a>, then used after &. Its name has no special duration. Renaming 'a to 'label changes neither validity nor ownership.

The first_label function is intentionally different: only its first input is related to the return type. The second input may stop being valid after the call. Giving both inputs the same lifetime as the return would unnecessarily restrict callers, even if the body always returned left.

Conversely, longer_label may return either input, so a caller cannot retain its result past the shorter-lived input's valid region merely because today's strings happen to select the other input. This is part of the API contract, not runtime lifetime tracking.

Borrow validity is not always an entire lexical block. Rust can stop a borrow after its last relevant use, as seen in the borrowing lesson. But a reference used after its owner is dropped remains invalid; annotations cannot change that ownership fact.

Lifetime elision covers common signatures

byte_length has no borrowed return value, so no output relationship is needed. In a function such as fn identity(input: &str) -> &str, one input lifetime determines the output lifetime. With two independent borrowed inputs, elision cannot choose the source of a borrowed return, which is why longer_label is explicit.

Methods with a shared or mutable self reference assign elided output-reference lifetimes to that receiver. Therefore changing DeviceView::label to fn label(&self) -> &str would tie the output to the borrow of the view, rather than explicitly expose the backing label's 'a. Our retained-label use then fails when the view ends. Refer to the elision rules.

Keep elision when its relationship is what you want. '_ requests an inferred lifetime in a position where that syntax is permitted; it is not an instruction to ignore lifetime checks. An annotation should communicate a real borrowing relationship, not serve as decoration.

Borrowed structs do not own their backing storage

DeviceView<'a> contains a reference, not a String. Its owner can drop the view while the label remains valid. The explicit accessor copies a shared reference whose validity follows the backing data; it does not borrow bytes owned by the view.

This technique would not apply to a structure that owns a String field: a reference into that field cannot survive destruction of that structure. It also does not grant unrestricted access while a mutable borrow exists. Choose an owned String when your result needs independent ownership, or keep the original owner alive when borrowing is the intended API.

Outlives bounds, static and a first look at variance

The bound 'long: 'short means 'long outlives 'short, not the reverse. A type bound T: 'a requires its contained lifetime parameters to outlive 'a. For example, a generic borrowed holder can combine a type parameter with a lifetime; bounds concern any references that type may contain. See lifetime bounds.

A &'static str can refer to a literal stored for the program's lifetime. A T: 'static bound is different: an owned String can satisfy it because it contains no non-static borrowed references, yet that String is still dropped normally. Adding 'static to a function returning a local String's borrowed bytes does not fix the invalid return.

Shared references can be shortened to a region in which their data remains valid. This is an example of lifetime covariance. Mutable references also allow shortening their own borrow lifetime, but are invariant in their referent type; not every nested-reference substitution is allowed. You need not implement unsafe code to use these rules. The Rustonomicon's variance discussion explains why the distinction prevents invalid writes; this is an introduction, not a complete aliasing model.

Higher-ranked bounds such as for<'a> express a requirement for every applicable lifetime, rather than one selected lifetime. We will introduce their practical use with closures and borrowed callbacks; they are not needed for this example.

Deliberately failing: returning a local owner's data

In a separate scratch project, replace src/main.rs with:

1
2
3
4
5
6
7
8
fn temporary_label<'a>() -> &'a str {
    let label = String::from("Pi 4B");
    &label
}

fn main() {
    println!("{}", temporary_label());
}

cargo check reports E0515. The function drops label before its caller can use the borrowed bytes. The caller-chosen 'a cannot make local storage persist. Returning an owned String transfers ownership and is an appropriate repair when this function constructs the data.

Exercises and troubleshooting

  1. Remove the lifetime declaration and all 'a annotations from longer_label: expect E0106 at its borrowed return type, because it has two possible input sources.
  2. Pass a long-lived primary label and an inner-block String to longer_label, save the result, then print it outside that block: expect E0597, even if the primary label has more bytes. Move the print into the valid region or return owned text.
  3. Change DeviceView::label's return type to elided &str: expect E0597 in the retained-view example. Restore the explicit backing-data lifetime when that is the intended contract.
  4. Create a view borrowing a mutable String binding, call push_str on that owner, then use the view: expect E0502. Mutability of the binding does not invalidate the existing shared-borrow rule.
  5. A function accepting &'static str should reject a borrow of a local String (E0597). In contrast, pass an owned String to fn owned<T: 'static>(value: T) -> T { value }: it can be returned and dropped normally.
  6. Repair temporary_label to return String and return label by value: expect Pi 4B. Adding 'static to the borrowed result is not a repair.

When an error says “does not live long enough”, identify the owner, the last use and the signature connecting them. When a lifetime specifier is missing, determine the relationship before inserting 'a everywhere. Do not clone or leak data merely to silence an error without choosing an ownership policy.

Verification and next step

On October 10, 2026, the lesson was verified on a Raspberry Pi 4B with 64-bit user space, kernel 6.18.50+rpt-rpi-v8, Rust and Cargo 1.99.0, and edition 2024. Cargo check, all seven tests, formatting and debug/release output comparisons passed. Returning owned text, passing String through a static type bound and shortening a reference under an outlives bound also passed. Local-data return, ambiguous elision, escaping either-input selection, receiver-tied output, conflicting mutation and a local borrow passed to a static-reference parameter failed as expected (E0515, E0106, E0597 and E0502 as applicable). No live sensor or performance result is involved.

Next: trait objects and dispatch, including the separate meanings of dyn Trait and impl Trait.

Previous: traits and associated types · Course overview

Donate