Reference Safety

How the Rust borrow checker prevents dangling references and data races by enforcing strict borrowing rules, covering local borrows, function parameters, return values, and structs with reference fields

A reference in Rust is a safe pointer: it always points to valid memory, and the compiler guarantees that it never outlives the data it borrows. That guarantee is not a runtime check — it is enforced entirely at compile time by the borrow checker, a part of the compiler that analyzes how every reference is created, used, and dropped. When a Rust program compiles, you know there are no dangling references, no use‑after‑free errors, and no data races caused by simultaneous aliased mutation.

Terminology reminder:

Throughout this section, "reference" means either a shared reference (&T) or a mutable reference (&mut T). When the distinction matters, the text makes it explicit.

This document walks through the concrete situations where reference safety rules show up in everyday Rust code: borrowing a local variable, receiving and passing references across function boundaries, returning references, and storing references inside structs. Each situation brings a different angle on the same underlying principle — a reference must never outlive the data it points to, and the rules for shared and mutable borrows must never be violated.

The Two Core Borrowing Rules

Understanding reference safety starts with two rules that the borrow checker enforces without exception:

  1. At any given time, you can have either one mutable reference or any number of shared references to a particular piece of data, but never both simultaneously.
  2. References must always be valid — they must never outlive the data they point to.

Rule 1 prevents data races. If one part of the program can mutate data while another part is reading it, the result is unpredictable without synchronization. Rust eliminates that entire class of bugs by rejecting programs that would allow such overlaps.

Rule 2 prevents dangling pointers. If a reference could exist after the memory it refers to has been deallocated, any use of that reference would be undefined behavior. The borrow checker ensures that every reference’s lifetime is contained within the lifetime of the data it borrows.

These rules are checked statically. The compiler does not insert reference counting, garbage collection, or runtime validity checks. It just refuses to compile code that breaks the rules — and the error messages tell you exactly why.

Borrowing a Local Variable

The simplest case of reference safety is taking a reference to a local variable. The borrow checker makes sure that the reference does not live longer than the variable it borrows.

fn main() {
    let x = 42;
    let r = &x;
    println!("{}", r);
}

Here, x lives until the end of main. The shared reference r is created after x and is dropped before x goes out of scope, so the borrow is trivially safe. The compiler has no objection.

A dangerous scenario is when a reference is stored in a location that can outlive the borrowed data. The simplest way to produce this is to assign a reference to a variable declared in an outer scope, then let the borrowed data go out of scope while the reference remains usable.

fn main() {
    let r;
    {
        let x = 42;
        r = &x;
    } // x is dropped here
    println!("{}", r); // r still refers to x, but x no longer exists
}

This does not compile. The borrow checker detects that x does not live long enough for r to still be used after the inner block. The error message explicitly states that x does not live long enough.

Dangling reference prevented at compile time:

A dangling reference in C or C++ leads to undefined behavior — the program may crash, produce wrong results, or quietly corrupt memory. Rust catches the same mistake before the program ever runs.

The mental model that helps here is to think of a borrow as a temporary permission slip. The variable that owns the data (here x) grants access only within its own lifetime. When the owner goes away, all outstanding permission slips become worthless. The compiler tracks this and rejects any code that would try to use an expired slip.

Receiving References as Parameters

When a function takes a reference as a parameter, the borrow checker ensures that inside the function body, the reference is used safely. More importantly, it ensures that the caller passes a reference that will remain valid for the entire duration of the function call.

Consider a function that prints the length of a string slice:

fn print_length(s: &String) {
    println!("The length is {}", s.len());
}
fn main() {
    let name = String::from("Rust");
    print_length(&name);
    // name can still be used here
    println!("{}", name);
}

The borrow &name is valid because name lives for the whole scope of main, which includes the call to print_length. The function does not store the reference anywhere that could outlive the call — it just reads the value and returns. That is safe.

The real danger arises when a function tries to smuggle a reference into a location with a broader lifetime, such as a global variable, a thread, or a data structure that may outlive the call. Rust blocks this in safe code. For example, trying to assign a parameter reference to a static variable is a compile error because a static lives for the entire program, and the function’s input reference is guaranteed only for the duration of that one call.

static mut CACHE: Option<&String> = None;
fn cache_reference(s: &String) {
    unsafe {
        CACHE = Some(s); // This compiles only inside `unsafe`
    }
}

The compiler would refuse to allow this in safe code, because it cannot prove that the static CACHE will not be accessed after the referenced String has been dropped. The burden of proof would be on the programmer, and the unsafe keyword marks exactly that boundary.

Do not escape references through globals or threads:

Moving a reference into a location with a broader lifetime — like a global, a thread spawned with std::thread::spawn, or a collection that outlives the function — requires explicit care with lifetimes or unsafe. The compiler will reject safe attempts that cannot be proven sound.

Passing References as Arguments

The caller’s side of a function call is where the aliasing rule (one mutable XOR many shared) is most often violated. The borrow checker looks at the order of borrows and their usage to determine whether a new borrow is allowed.

Imagine you have a mutable variable and you take a shared reference to it. While that shared reference is still alive, you cannot create a mutable reference to the same variable.

fn main() {
    let mut data = vec![1, 2, 3];
    let first = &data[0];      // shared borrow
    data.push(4);              // mutable borrow — ERROR
    println!("{}", first);     // shared borrow still used here
}

The compiler error is:

error[E0502]: cannot borrow `data` as mutable because it is also borrowed as immutable

The problem is that first is a shared reference into the vector’s internal buffer. If push were allowed to proceed, it might reallocate the buffer, invalidating first. The compiler sees that first is still live at the point of the mutable borrow and rejects the program.

The same rule applies in reverse: while a mutable reference exists, no other references — shared or mutable — may coexist. This prevents aliased mutation entirely.

fn main() {
    let mut value = 10;
    let r1 = &mut value;
    let r2 = &mut value; // ERROR: second mutable borrow
    *r1 += 1;
}

Aliased mutation is forbidden:

Rust’s type system enforces that mutable references are unique. There is no safe way to have two paths to the same mutable data at once. This guarantee prevents the kind of aliasing bugs that lead to subtle data corruption in languages like C++.

These rules apply across function boundaries. When you pass a reference to a function, the borrow checker considers the borrow to last for the duration of that reference’s use in the caller. If a function takes a shared reference, the caller cannot mutably borrow the same data until the shared reference is no longer used. If a function takes a mutable reference, the caller cannot have any other live borrow of that data.

Understanding the scope of a borrow is often where beginners get stuck. The borrow lasts from the point where the reference is created until its last use. So reordering statements can make a difference:

fn main() {
    let mut data = vec![1, 2, 3];
    let first = &data[0];
    println!("{}", first);   // last use of first
    data.push(4);            // now allowed: first is no longer live
}

After the println!, first is no longer needed, so the shared borrow ends, and the mutable borrow for push is allowed. This behavior is part of Rust’s non‑lexical lifetimes, which the compiler uses to be more permissive without sacrificing safety.

Returning References

Returning a reference from a function is the tightest spot where reference safety must be proven. The compiler needs to know that the returned reference is tied to some input data — or to data with a 'static lifetime — and never to a local variable that will be destroyed when the function returns.

A classic mistake: trying to return a reference to a value created inside the function.

fn dangle() -> &String {
    let s = String::from("hello");
    &s // ERROR: `s` does not live long enough
}

The compiler will tell you that s will be dropped at the end of dangle, so a reference to it cannot be returned. There is no way around this in safe Rust.

To return a reference successfully, you must return one that was passed in as a parameter, or one derived from such a parameter. The function signature then needs explicit lifetime annotations to tell the compiler how the input and output lifetimes relate. (Lifetime annotation syntax is covered in depth in the next section; here we focus on the safety concept.)

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

The lifetime parameter 'a tells the compiler: the returned reference lives as long as the shorter of the two input references. The caller can then use the return value only while both inputs are still valid. That ensures safety.

Lifetime annotations make reference safety explicit:

When you see a function like longest compile, you know the compiler has verified that no dangling reference can escape. The annotations are not just syntax — they are a formal proof that the output reference is bounded by the lifetimes of the inputs.

There is one special case: references with the 'static lifetime, which live for the entire program. String literals, for example, have the type &'static str. You can safely return a string literal from any function because it will never be deallocated.

fn greeting() -> &'static str {
    "Hello, world!"
}

This is safe because "Hello, world!" is baked into the binary and exists for the lifetime of the program.

Structs Containing References

When a struct holds a reference field, the compiler must ensure that any instance of the struct does not outlive the data that field points to. To express this, the struct needs a lifetime parameter that connects the struct’s lifetime to the referenced data’s lifetime.

struct Excerpt<'a> {
    text: &'a str,
}
fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = Excerpt { text: &novel[..15] };
    println!("{}", first_sentence.text);
}

Here, Excerpt<'a> says: any Excerpt value has a lifetime 'a, and its text field is a reference that must be valid for at least 'a. Because the instance first_sentence is used only while novel is still alive, the program compiles.

If you try to let the struct outlive the borrowed data, the borrow checker steps in.

struct Excerpt<'a> {
    text: &'a str,
}
fn main() {
    let excerpt;
    {
        let novel = String::from("data");
        excerpt = Excerpt { text: &novel };
    } // `novel` dropped here
    println!("{}", excerpt.text); // ERROR
}

The error message will say that novel does not live long enough. The lifetime 'a of the struct is tied to novel, but excerpt is used after novel is gone, so the compiler rejects it.

Forgetting lifetime parameters on structs:

If you write struct Excerpt { text: &str } without a lifetime annotation, the compiler will ask you to add one. It cannot infer the lifetime relationship and refuses to compile until you make the dependency explicit.

Structs with references are common in Rust libraries that avoid heap allocation. They let you build views into existing data without cloning. The lifetime annotations are the price you pay for that efficiency — they make the safety contract visible so that the compiler can enforce it.

How a Beginner Should Think About Reference Safety

The borrow checker can feel like a pedantic gatekeeper that rejects code you “know” is fine. A more productive mental model is that the borrow checker is describing the shape of valid ownership relationships in your program. When it rejects your code, it is telling you that you have structured the lifetimes or the aliasing of your data in a way that cannot be statically guaranteed to be correct.

Instead of fighting the compiler, use its feedback to reorganize your data flow. Shorten borrows by using references only where needed and releasing them early. Copy or clone data when the lifetime constraints become too tight and performance is not critical. Restructure functions so that data that must outlive a reference is owned at a higher scope.

The reward for aligning your code with these rules is that an entire class of memory bugs simply does not exist. You never chase a dangling pointer or a race condition that stems from shared mutation — the compiler has already proven they are absent.