What Is Non-Lexical Lifetimes (NLL) in Rust?

NLL is a Rust compiler feature that tracks reference validity based on actual usage points rather than lexical scope.

When the borrow checker feels too clingy

You are refactoring a function that processes a configuration string. You borrow the string at the top to log its current value. You use the reference in a println!. Then, a few lines later, you want to clear the string and write a new value. The compiler rejects the code. It says you cannot borrow config mutably because it is already borrowed immutably.

You look at the code. You stopped using the immutable reference three lines ago. The variable holding the reference is still in scope, but you never read it again. The borrow checker feels like it is holding onto the reference long after you put it down. It treats the borrow as alive until the closing brace of the function, ignoring the fact that the data is no longer needed.

Non-Lexical Lifetimes (NLL) solves this. NLL tells the compiler to track borrows based on actual usage, not just the structure of the code. With NLL, the borrow ends the moment you stop using the reference. You can mutate the data immediately after the last read, even if you are still deep inside the same function.

Lexical scope versus usage

Rust's borrow checker enforces two rules: you can have either one mutable reference or any number of immutable references, but not both at the same time. The question is how long a reference lives.

Before NLL, Rust used lexical lifetimes. A lexical lifetime is tied to the lexical scope of the variable. A scope is a block of code wrapped in braces. If you created a reference inside a block, that borrow lasted until the closing brace. The compiler did not care if you used the reference again. It only looked at the text structure.

NLL changes the rule. The compiler now tracks the last use of a reference. It analyzes the data flow to find the final point where the reference is read or written. The borrow ends at that point. The scope still exists, but the borrow does not.

Think of a tool in a workshop. Lexical lifetimes are like a rule that says once you pick up a hammer, you must keep holding it until you leave the room. You can't pick up a saw while you are holding the hammer, even if you finished nailing the board and set the hammer on the bench. NLL says you can put the hammer down the moment you finish using it. You can pick up the saw immediately, as long as you are not holding the hammer.

NLL is enabled by default in all modern Rust versions. It has been stable since Rust 1.31. You do not need to enable it. It just works.

Minimal example

This code compiles and runs because NLL sees that r1 is no longer used before r2 is created.

fn main() {
    let mut s = String::from("hello");

    // Create an immutable reference.
    let r1 = &s;

    // Use the reference. This is the last time r1 appears.
    println!("{}", r1);

    // NLL ends the immutable borrow here.
    // The variable r1 exists, but the borrow is dead.
    // We can create a mutable borrow safely.
    let r2 = &mut s;
    r2.push_str(" world");

    println!("{}", s);
}

Without NLL, the compiler would reject the creation of r2. It would see r1 in scope and assume the immutable borrow is still active. NLL looks at the println! and realizes r1 is never read again. The borrow window shrinks to fit the usage.

The borrow ends at the last use, not the closing brace.

How the compiler tracks borrows

The compiler implements NLL using an intermediate representation called MIR (Mid-level Intermediate Representation). When you write Rust code, the compiler first desugars it into MIR. MIR is a low-level, three-address code that makes every operation explicit.

In MIR, every variable has a live range. The borrow checker walks through the MIR and marks borrows as active when they are created. It marks them as inactive when the variable is last used. It builds a graph of these intervals. If a mutable borrow overlaps with an immutable borrow in the graph, the compiler rejects the code.

This analysis happens in the mir_borrowck pass. The pass is precise. It handles control flow, loops, and closures. It knows that a borrow in one branch of an if statement might not be alive in the other branch. It knows that a borrow in a loop iteration ends before the next iteration starts, unless the reference escapes.

Convention aside: In the early days of Rust, developers wrote drop(r1); to force a borrow to end. That pattern is a code smell now. If you need drop() for a reference, your code structure is fighting the compiler. NLL makes explicit drops of references almost obsolete. Use drop() only for owned values that implement Drop, like file handles or database connections.

The compiler tracks the data, not the braces. Trust the analysis.

Realistic example

NLL shines in functions where you need to read data, process it, and then update the source. This pattern is common in parsers, state machines, and configuration loaders.

/// Updates the configuration string after logging the previous value.
fn update_config(config: &mut String, new_value: &str) {
    // Create an immutable reference for logging.
    let log_ref = &config;

    // Use the reference. This is the final use of log_ref.
    println!("Old config: {}", log_ref);

    // NLL ends the borrow here.
    // We can mutate config immediately.
    // Without NLL, this would fail with E0502.
    config.clear();
    config.push_str(new_value);
}

fn main() {
    let mut cfg = String::from("debug=true");
    update_config(&mut cfg, "debug=false");
    println!("New config: {}", cfg);
}

If you try to mutate config while log_ref is still live, the compiler rejects the code with E0502 (cannot borrow as mutable because it is also borrowed as immutable). NLL does not relax the aliasing rules. It only makes the borrow window tighter. If your logic requires overlapping borrows, the compiler still stops you.

Write the code you want. If the borrows don't overlap, NLL lets you pass.

Pitfalls and limits

NLL makes the borrow checker smarter, but it does not remove the rules. Several patterns still trip up developers who assume NLL fixes everything.

Closures capture variables by reference. If a closure captures a reference, the borrow extends to the point where the closure is called. NLL tracks this, but the borrow can live longer than you expect.

fn main() {
    let mut s = String::from("hello");
    let r = &s;

    // The closure captures r.
    let closure = || println!("{}", r);

    // This fails. The borrow lives until the closure is called.
    // s.push_str(" world"); // Error E0502

    closure();
    // Now the borrow ends.
    s.push_str(" world");
}

The compiler rejects the mutation with E0502. The closure holds the reference alive. NLL sees the closure call and extends the borrow window to that point. You cannot mutate s until the closure is done.

NLL also does not help with logic errors. If you hold a reference and mutate the data, you get an error. NLL only shrinks the borrow window to the last use. It does not allow aliasing. If your algorithm requires a mutable reference and an immutable reference to coexist, you need to restructure the code. You might need to clone the data, use RefCell for interior mutability, or split the logic into separate functions.

Another pitfall is temporary values. NLL extends the lifetime of temporaries in some cases, but not all. If you bind a temporary to a variable, the lifetime rules apply normally.

fn main() {
    // This works. The temporary String lives until the end of the statement.
    let r = &String::from("hello");
    println!("{}", r);
}

If you try to return a reference to a local variable, NLL does not save you. The variable is dropped at the end of the function. The reference would dangle. The compiler rejects this with E0515 (does not live long enough).

NLL makes the rules tighter, not looser. If your logic requires aliasing, no amount of NLL magic will save you.

Decision matrix

NLL is always on. You do not choose to use it. You choose how to structure your code to work with it.

Rely on NLL when you write linear code where borrows end before mutations. You do not need to add scopes or drop() calls. The compiler tracks the last use automatically.

Add explicit scopes when you create a temporary value that holds references and you want to ensure those references die before a mutation. A block { ... } makes the intent clear to humans, even if NLL handles the compiler. Scopes group logic and limit the visibility of variables.

Use drop() only for owned resources like file handles, database connections, or locks. Do not use drop() to end borrows. It signals a misunderstanding of NLL and clutters the code.

Refactor into smaller functions when NLL errors persist. If you cannot get the borrows to line up, the function is likely doing too much. Splitting logic often resolves lifetime conflicts naturally. Smaller functions have fewer variables and clearer data flow.

Trust the compiler's precision. You do not need to manage lifetimes manually anymore.

Where to go next