Non-lexical Lifetimes (NLL)

How the Rust borrow checker uses control-flow liveness analysis to accept more safe code by ending borrows at their last use, not at block scope boundaries

What Are Non-lexical Lifetimes

Non-lexical lifetimes (NLL) is the improvement to Rust’s borrow checker that makes borrows end at the point where the borrowed reference is last used, rather than at the end of the block (scope) where the variable was declared. The name captures the shift: lifetimes are no longer determined purely by the lexical structure of braces; they are shaped by the control-flow of the program.

Before NLL (pre-2018 edition), the borrow checker used a simpler rule: a borrow lives as long as the variable that holds it is in scope. That often meant a borrow stayed alive through code that didn’t actually use the reference, blocking perfectly safe operations. NLL replaces that with a precise analysis: “does any future code rely on this reference?” If the answer is no, the borrow ends.

NLL is the default everywhere:

Since Rust 1.36, NLL has been enabled for all editions, including 2015. You’re already using it whenever you write Rust today.

This change made the borrow checker accept a strict superset of safe programs — everything that used to compile still compiles, and many programs that used to be rejected now pass.

Why NLL Was Needed

The lexical model frequently rejected safe code that any programmer would look at and think “this is obviously fine.” The root cause: the compiler couldn’t see that a reference was dead after its last use.

Consider this seemingly innocent extraction of a temporary into a variable:

fn capitalize(data: &mut [char]) {
    // ... modify data ...
}
fn main() {
    let mut data = vec!['a', 'b', 'c'];
    let slice = &mut data[..];   // borrow starts
    capitalize(slice);           // last use of `slice`
    data.push('d');              // error before NLL
}

Under the old borrow checker, slice would live until the closing } of main, because that’s where its lexical scope ends. The data.push('d') call creates a second mutable reference through data, overlapping with the still-live &mut borrow — a violation of the “exclusive mutable reference” rule.

But slice isn’t used after the call to capitalize. There is no real conflict. The only reason the compiler rejected the code was the coarse granularity of the lifetime.

The workaround was to introduce an artificial block:

{
    let slice = &mut data[..];
    capitalize(slice);
}
data.push('d'); // allowed because slice is out of scope

This worked, but it felt like fighting the compiler over something the programmer already knew.

The same pain appeared in many other patterns:

  • Checking a map and then inserting in the None arm of a match.
  • Conditionally using a reference inside one branch of an if and mutating in another.
  • Returning a reference from a function after a match where one arm ends the borrow early.

In all these cases, the borrow’s liveness (do we still need the reference?) and its scope (where the variable exists) were out of sync. NLL bridges that gap.

Not about weakening safety:

NLL doesn’t relax Rust’s safety guarantees. It makes the borrow checker more precise — it still rejects every unsafe situation, it just stops rejecting safe ones that it previously misjudged.

How NLL Works

NLL is built on two internal compiler changes:

  1. A control-flow-graph (CFG) based intermediate representation — MIR (mid-level intermediate representation) — that explicitly models how a program flows, including branches, loops, and early returns.
  2. A liveness analysis that computes, for every borrow, the exact set of program points where the borrowed reference might still be read.

A borrow is considered live from its creation up to its last use. After that last use, the compiler considers the borrow ended — regardless of whether the variable that held it is still in scope. This is the core insight: the lifetime of a borrow is the liveness of the reference, not the scope of the variable.

With that information, the borrow checker can compare borrows for potential overlap much more accurately. If two borrows don’t overlap in time (their live ranges are disjoint), having a shared and a mutable borrow at the same variable is fine — even if they are in the same block.

The reference, not the variable:

NLL tracks liveness of the borrowed reference, not the variable that stores it. If you assign a reference to a variable and then use that variable later, the borrow is live through that later use — even if the first “obvious” use was much earlier. We’ll see this pitfall in the limitations section.

The analysis works at the MIR level, which replaces the old AST-based borrow checker. That move not only enabled NLL but also fixed long-standing soundness bugs — cases where the old checker erroneously accepted code that was actually unsafe. So NLL both accepts more safe code and rejects more unsafe code.

Practical Examples of What NLL Allows

These are realistic patterns that used to require explicit scoping and now just work.

Storing a reference and later mutating the original

The example from the introduction is the simplest case. The mutable slice slice is last used as the argument to capitalize. After that point, it’s dead, so data.push('d') succeeds.

fn capitalize(data: &mut [char]) {}
fn main() {
    let mut data = vec!['a', 'b', 'c'];
    let slice = &mut data[..];
    capitalize(slice);
    data.push('d');          // allowed: slice’s borrow ended at the call to capitalize
    data.push('e');
    println!("{:?}", data); // ['a', 'b', 'c', 'd', 'e']
}

The compiler sees that slice is not used after line 6, ends the mutable borrow there, and accepts the subsequent pushes.

HashMap entry pattern

A common task: look up a key, and if it’s missing, insert a default. Without NLL, the entire match block was considered a single borrow.

use std::collections::HashMap;
fn process_or_default(map: &mut HashMap<String, i32>, key: &str) {
    match map.get_mut(key) {
        Some(value) => {
            *value += 1;      // last use of the mutable borrow from get_mut
        }
        None => {
            // no live borrow here anymore: the Some arm ended the borrow
            map.insert(key.to_string(), 1);
        }
    }
}

Before NLL, calling map.insert inside the None arm would fail because the borrow from get_mut was considered alive across the entire match. NLL recognises that the Some arm consumed the last use of the borrow; entering the None arm means the borrow is dead, so the mutable access is conflict-free.

Conditional use of a reference

NLL also allows borrows that are live only on one path through the code:

fn maybe_print(data: &mut Vec<String>) {
    if let Some(first) = data.first() {
        println!("first: {}", first);  // immutable borrow used only here
    }
    // borrow ended after the if body (if entered) or never started (if not)
    data.push("new element".to_string()); // mutable borrow now allowed
}

If the if let matches, the immutable borrow from first() is used in the println! and then dropped. If it doesn’t match, no borrow ever lives. In both cases, the mutable borrow at the push is allowed. The old checker couldn’t see that the borrow was confined to the if body; NLL can.

Don't confuse liveness with variable existence:

If the reference is stored in a variable and that variable is used later — even just for a print — the borrow will remain live through that later use. Don't assume NLL will magically end a borrow immediately after a function call if the variable holding the reference appears again.

Limitations of NLL

NLL’s precision is bounded by liveness: a borrow lives as long as there is a possible future use. That’s a huge improvement over lexical scopes, but it’s not perfect. Some safe patterns still fail because the compiler sees a possible use that the programmer knows will never happen in practice.

Borrows kept alive by later uses

fn keep_alive_demo() {
    let mut v = vec![1, 2, 3];
    let r = &mut v;        // mutable borrow starts
    modify(r);             // last use of r
    // Even though we don't use r again, the following line fails
    // v.push(4);          // error: cannot borrow v as mutable because it is also borrowed as mutable
    // Why? r might still be used later, but in this snippet it isn't.
    // Actually, this *does* work because the borrow ends at the call to modify.
    // A true limitation example: using r after push would be a conflict, but NLL can't help if r is actually used later.
}

Actually, the classic keep_alive pitfall is when you store a mutable reference and then use it after a conflicting access. For instance:

fn limitation_example() {
    let mut data = vec![10, 20];
    let r = &mut data[0];
    *r += 5;
    data.push(30);       // error: mutable borrow `r` is still live because r is used later?
    println!("r = {}", r);
}

Here r is used after the push, so the mutable borrow truly overlaps the push — that is genuinely unsafe, and NLL correctly rejects it. The “limitation” is that NLL cannot shorten the borrow to end before push because the reference is live at the println!. That’s not a flaw; it’s correctness. The programmer must reorder the code so the push happens after the last use of r, or restructure to avoid the overlapping borrow.

What trips people up is expecting NLL to end a borrow at the point where it’s “no longer needed in the algorithm,” even when syntactically the reference appears later. NLL sees the later use and extends the borrow accordingly. This is the most common misunderstanding.

Patterns that still need explicit scopes

Even with NLL, you may occasionally need to introduce an explicit block to end a borrow early. This typically happens when a reference is stored in a variable that you can’t restructure to avoid a later use, but you still need a conflicting access in between.

fn still_need_scope() {
    let mut data = vec![1, 2, 3];
    let r = &data[0];
    // ... some work ...
    // If you need to mutate data while r still exists (but r isn't used after this point),
    // you can put r into a smaller scope:
    {
        let r = &data[0];
        println!("{}", r);
    } // borrow ends
    data.push(4); // allowed
}

This is exactly the old workaround, now used only when the borrow’s liveness genuinely extends beyond the point where you want to mutate. With NLL, you need it far less often.

Runtime-checked borrows (RefCell)

NLL operates entirely at compile time. Types like RefCell that move borrow checks to runtime are unaffected. A RefCell shared borrow still blocks a mutable borrow at runtime, even if the shared borrow is “dead” in the NLL sense, because the runtime doesn’t know about liveness.

use std::cell::RefCell;
fn nll_no_help_for_refcell() {
    let data = RefCell::new(vec![1, 2, 3]);
    let b = data.borrow();          // immutable runtime borrow
    drop(b);                        // must explicitly drop to release
    let mut bm = data.borrow_mut(); // now allowed
    bm.push(4);
}

NLL can’t infer that b is unused after the drop because RefCell doesn’t rely on static lifetime analysis. The drop is still needed — or better, just restructure so the borrow doesn’t span the mutation point.

Unsoundness fixes may break existing code

Because NLL is implemented on a MIR-based borrow checker that more faithfully models Rust’s semantics, some programs that compiled under the old AST-based checker (but were actually unsound) will be rejected. The most common cause involves closures that capture variables and the old checker not properly tracking moves. An example:

fn previously_accepted_unsound() {
    let x = String::from("hello");
    let f = || drop(x);    // closure moves x
    // x is moved, so using x would be unsound
    // println!("{}", x);  // error with NLL (and rightfully so)
}

The old checker sometimes accepted unsound code that NLL will now reject. This is a good thing, but it’s worth knowing if you’re migrating older Rust codebases.

The Move to MIR and Fixed Soundness Holes

The NLL shift was the vehicle for a larger compiler upgrade: abandoning the AST-based borrow checker for a new one operating on the control-flow-aware MIR. The old AST checker had to reconstruct control-flow from the syntax tree, which led to discrepancies — in some cases, the modelled control-flow didn’t match what actually happened at runtime, so unsafe code slipped through.

By translating the program into MIR — a simplified graph of basic blocks — all control flow (including panics, early returns, and complex match desugaring) is explicit. The NLL analysis runs directly on that graph, so there is no mismatch between the analysed model and reality.

The analysis behind NLL:

The core algorithm is a region-inference problem over the MIR control-flow graph: for each borrow, a set of “points” where it can be live is inferred, constrained by the rule that a borrow must contain all uses. The minimal solution gives the shortest possible lifetimes. This is the foundation for the “non-lexical” behaviour.

The soundness fixes that came along with this were a welcome side effect, not the primary goal, but they are significant. They include:

  • Proper handling of borrows inside closures that escape.
  • Correct borrow extents in match guards.
  • Accurate drop-scope handling (borrows must not leak into destructors incorrectly).
  • Rejecting partial moves that could leave a variable in an invalid state.

These fixes make Rust even safer, but they also mean that some rare programs that previously compiled might now see new errors. The migration period (2018 edition with migration mode) allowed code to transition gradually.

Common Misconceptions About NLL

  • “NLL means borrows end as soon as I’m ‘done’ with them conceptually.”
    No. The borrow ends at the last syntactic use of the reference. If you log the reference at the very end of the function, the borrow extends that far, even if the log is irrelevant to safety. Programmer intent doesn’t shorten lifetimes; code does.

  • “I can now have both a mutable and immutable reference to the same data, as long as I don’t interleave reads and writes.”
    That’s still the same rule: you cannot have a mutable reference coexisting with any other reference. NLL only makes the time of coexistence shorter — it doesn’t remove the exclusivity requirement.

  • “Explicit drops are never needed now.”
    They’re rarely needed, but there are still cases where you want to end a borrow earlier than its natural last use — maybe to call a function that takes a mutable reference before a later, non-conflicting use of the immutable borrow. The explicit block or drop is the way to do that.

  • “NLL makes the borrow checker accept any code that’s actually safe.”
    It accepts a much larger set, but far from all safe code. Some safe patterns still require unsafe code, interior mutability, or careful restructuring. The next-generation borrow checker (Polonius) aims to capture more, but it’s still in development.

Watch your later uses:

The most frequent mistake is storing a reference, performing some operation that would require a conflicting borrow, and then using the reference later. The compiler will complain about the conflict because the borrow is still live at that later use. Ask yourself: “Is there a way to move the conflicting operation after I truly don’t need the reference anymore?”

Summary

Non-lexical lifetimes transformed the experience of writing Rust. They eliminated the need to artificially scope references and removed a large class of frustrating “but this is safe!” compile errors. This was achieved by building the borrow checker on top of a control-flow-graph intermediate representation and performing liveness analysis — letting lifetimes reflect where values are actually used, not where braces happen to open and close.

The key takeaway: the borrow checker now sees more of the truth. It still enforces the same strict rules, but it no longer blinds itself by assuming a reference is alive just because its variable hasn’t left scope. For the majority of code, this means you can write directly what you mean, and the compiler will understand.