What is NLL Non-Lexical Lifetimes

NLL is Rust's advanced borrow checker that tracks reference lifetimes based on actual usage points rather than syntactic scopes.

The brace trap

You are refactoring a function that processes a buffer. You pull a reference to the first byte, check if it matches a magic number, and then clear the buffer to start fresh. The compiler yells E0502 (cannot borrow as mutable because it is also borrowed as immutable). You stare at the code. The reference is only used in the if condition two lines up. You haven't touched it since. Why does the compiler think you are still holding it?

You move the reference to the very end of the block, after the clear, and the error vanishes. It feels like the borrow checker is punishing you for the shape of your braces, not what you are actually doing. You start wrapping things in nested scopes just to appease the compiler, and your code looks like a fractal of curly braces.

That feeling is the old borrow checker. It tracked borrows based on lexical scope. If you created a reference at the top of a block, that borrow lasted until the closing brace, even if you never used the reference again. NLL fixes this. NLL stands for Non-Lexical Lifetimes. It changes the rule so the borrow checker tracks the last actual use of a reference, not where the scope ends.

What NLL actually does

NLL is a feature of the Rust compiler that improves how lifetimes are calculated. The word "lexical" refers to the structure of your source code, specifically the text regions defined by braces. Before NLL, the compiler used a simple rule: a borrow lives from its creation until the end of the enclosing scope.

NLL replaces that rule with a smarter analysis. The compiler now builds a model of your code's control flow and tracks exactly where each variable is read or written. A borrow now lives from its creation until its last use. If you create a reference and never use it again, the borrow ends immediately. If you use it, then mutate the data, then use it again, the borrow spans that gap.

Think of it like a library book. In the old system, you checked out the book at the start of the semester, and you could not return it until the semester ended, even if you finished reading it in week two. The book was unavailable to everyone else for months. NLL lets you return the book the moment you close the cover. The shelf becomes available for others immediately.

NLL is enabled by default in all modern versions of Rust. There is no flag to turn it on or off. If you are learning Rust today, NLL is just "the borrow checker." The community treats it as the baseline. You do not need to opt in. You just write code that describes what you do, and the compiler figures out the lifetimes.

Minimal example

Here is the classic pattern that NLL unlocks. You borrow from a collection, use the borrow, and then mutate the collection.

fn main() {
    let mut vec = vec![1, 2, 3];
    
    // Borrow starts here. NLL tracks this interval.
    let first = &vec[0];
    
    // Last use of `first`. 
    // NLL sees this is the final read, so the borrow ends here.
    println!("First element: {}", first);
    
    // Mutable borrow. 
    // Safe because `first` is no longer active.
    vec.push(4);
    
    println!("Vec is now: {:?}", vec);
}

In the old checker, first would be considered alive until the closing brace of main. The push would overlap with the borrow, causing an error. NLL sees that first is not used after the println, so it shrinks the borrow interval. The push happens after the borrow ends. The code compiles.

Convention aside: You will see println! used in examples like this. The macro takes references to its arguments. NLL is smart enough to see that the borrow of first is consumed by the macro expansion and is not needed afterward. The borrow ends at the semicolon.

Walkthrough: Last use wins

Under the hood, the compiler performs a static analysis pass over your function. It builds a control flow graph, which is a map of all possible paths your code can take. It annotates every variable with a "borrow interval."

For every reference, the compiler marks the start point where the reference is created. Then it walks the graph to find the last point where that reference is accessed. That point becomes the end of the interval. If two intervals overlap and one is mutable, the compiler rejects the code. If they do not overlap, the code is safe.

This analysis happens entirely at compile time. There is zero runtime cost. NLL does not add checks to your binary. It does not slow down execution. It only changes what the compiler accepts. The generated machine code is identical to what you would get if you manually structured your scopes to match the borrows. NLL just saves you from doing that manual work.

NLL also handles complex control flow. If a borrow is used in one branch of an if but not the other, NLL calculates the interval based on the paths where the borrow is actually needed. This prevents false positives where the compiler assumes a borrow is alive in a branch where it is never used.

Write code that follows the logic of your algorithm. NLL bridges the gap between your intent and the compiler's rules.

Realistic example: The conditional check

NLL shines in realistic patterns where you inspect data and then modify it based on the inspection. Here is a function that checks a header byte and clears the buffer if the header is invalid.

fn process_buffer(mut buffer: Vec<u8>) -> Vec<u8> {
    // Borrow the first byte to check the header.
    let header = &buffer[0];
    
    // We only use `header` in this condition.
    // NLL sees `header` is dead after the comparison.
    if *header == 0xFF {
        // Mutable access is allowed here.
        // The borrow of `header` has ended.
        buffer.clear();
    }
    
    buffer
}

Without NLL, header would be alive until the end of the function. The clear call would fail because it requires a mutable borrow, and header holds an immutable borrow. NLL sees that header is only used in the if condition. The borrow ends right after the comparison. The clear happens in a safe window.

This pattern is common in parsers, protocol handlers, and state machines. You peek at data to decide what to do, then mutate the buffer. NLL lets you write this linearly without artificial scoping.

Ah-ha reveal: NLL also enables "two-phase borrows." This is a subtle feature where you can acquire a resource, check a condition, and then mutate the resource in the same phase if the check passes. For example, you can lock a mutex, check if the lock succeeded, and then push to the protected data, all without holding the lock guard across the mutation. NLL tracks the borrow of the mutex through the check and allows the mutation once the check is done. This eliminates a whole class of awkward lock management code.

Pitfalls and errors

NLL solves the "scope is too wide" problem. It does not solve every lifetime issue. You will still encounter errors when borrows truly conflict or when values do not live long enough.

NLL does not relax the aliasing rules. You still cannot have a mutable and immutable borrow active at the same time. NLL only shrinks the window where the immutable borrow is active. If your logic requires overlapping borrows, NLL will not save you. You will still see E0502 (cannot borrow as mutable because it is also borrowed as immutable) when the intervals genuinely overlap.

NLL also does not extend lifetimes. If you return a reference to a local variable, the compiler will still reject you with E0515 (cannot return value referencing local variable). NLL tracks when borrows end; it does not make data live longer than its owner.

Another pitfall is relying on NLL to hide intent. If you write code where a borrow ends early but the scope continues, a human reader might assume the borrow is still active. NLL makes the code compile, but it can confuse readers who are not familiar with NLL. In these cases, adding an explicit scope block { } can communicate your intent clearly. The block forces the borrow to end, and it signals to readers that the borrow is local.

Convention aside: The community often uses let _ = &value; to create a temporary borrow that dies immediately. This is a signal to the compiler and readers that you are touching a borrow and dropping it. It is useful when NLL might keep a borrow alive too long due to complex control flow, or when you want to force a borrow to end for clarity.

Trust the borrow checker, but write for humans too. If NLL makes your code compile but confuses your teammates, add a scope.

Decision matrix

NLL is automatic, but you still make decisions about how to structure your code. Use the following guidelines to choose the right approach.

Trust NLL when your borrows are short-lived and you want clean, linear code. Write the reference, use it, move on. The compiler tracks last-use accurately for the vast majority of code. Do not add scopes just to satisfy the compiler; NLL handles it.

Add explicit scopes when you need to communicate intent to humans. If a borrow ends early but the scope continues, a reader might think the borrow is still active. A block { let ref = ...; use(ref); } signals "this borrow is local." This improves readability without changing behavior.

Reach for RefCell when you need interior mutability and NLL still fights you over overlapping borrows that you know are safe at runtime. NLL helps with scope issues, but it does not replace the need for dynamic checks in some patterns. If you have a data structure where children need to mutate parents, or where borrows overlap in a way NLL cannot prove safe, RefCell is the tool.

Use let _ = &value; to force a borrow to end when NLL keeps it alive too long. This happens in complex control flow where the compiler cannot prove the borrow is unused. The temporary assignment creates a borrow that dies at the end of the statement, breaking the interval.

Rely on NLL for loops and iterators. Patterns like for item in &vec { ... } vec.push(...); work because NLL sees the borrow of vec in the loop ends when the loop ends. You do not need to collect or clone. Write the loop naturally.

NLL is a feature of the compiler, not the language. You do not opt in. It just works. Write code that describes what you do, not where you do it. NLL bridges the gap.

Where to go next