Question
Rust has had an approved RFC for non-lexical lifetimes (NLL) for quite some time, and support for this feature is now considered mature.
What exactly is a non-lexical lifetime in Rust, and how does it change the way the borrow checker reasons about references?
Short Answer
By the end of this page, you will understand what non-lexical lifetimes (NLL) are in Rust, why they were introduced, and how they make borrowing more precise. You will see how Rust used to tie borrows to larger code blocks, how NLL instead ends borrows at their last actual use, and why this allows more valid programs to compile safely.
Concept
In Rust, a lifetime describes how long a reference is valid. Before non-lexical lifetimes, the borrow checker often treated a borrow as lasting for the rest of the surrounding lexical scope—usually the enclosing block.
A lexical scope is the region of code determined by the source text itself, such as everything inside a pair of braces.
With non-lexical lifetimes, Rust became more precise. Instead of assuming a borrow lasts until the end of the whole block, the compiler can often determine that the borrow only needs to last until its last actual use.
That is the key idea:
- Lexical lifetime thinking: "This reference was created inside this block, so the borrow may last until the block ends."
- Non-lexical lifetime thinking: "This reference is only used until this line, so the borrow can end there."
This matters because Rust prevents:
- multiple conflicting mutable and immutable borrows
- mutation while an immutable borrow is still active
- use-after-free and data races
Without NLL, the compiler was sometimes correct but too conservative. It rejected code that was actually safe because it assumed borrows lasted longer than necessary.
With NLL, the compiler still enforces the same safety rules, but it tracks borrow usage more accurately. This means:
- fewer unnecessary borrow checker errors
- code that feels more natural to write
- no loss of memory safety
Here is a classic example:
fn main() {
let mut s = String::from("hello");
let r = &s;
println!("{}", r);
s.push('!');
}
With older lexical borrow behavior, Rust might reject this because r was created in the same block and the compiler could treat the borrow as lasting until the end of that block.
With NLL, the compiler sees that r is last used in println!, so the immutable borrow ends there. After that, s.push('!') is allowed.
So, non-lexical lifetimes are not a new kind of reference you write explicitly. They are an improvement in how the compiler infers and ends borrows based on usage rather than just source-code block boundaries.
Mental Model
Think of a borrow like borrowing a library book from someone.
Before NLL, Rust acted more like this:
- "You borrowed the book in this room.
- You keep control of it until you leave the room."
Even if you finished reading it halfway through the room, the compiler still assumed the borrow was active until the room ended.
With NLL, Rust acts more like this:
- "You borrowed the book.
- As soon as you are done using it, it is considered returned."
That means the original owner can use or modify it again sooner.
Another way to think about it:
- A lexical lifetime follows the shape of the code block.
- A non-lexical lifetime follows the actual dataflow of usage.
So NLL makes the borrow checker behave less like a rigid block-based rule system and more like a smart tracker of when references are really needed.
Syntax and Examples
Rust does not have special syntax for non-lexical lifetimes. You write ordinary borrowing code, and the compiler applies NLL automatically.
Basic example:
fn main() {
let mut name = String::from("Rust");
let first_borrow = &name;
println!("{}", first_borrow);
name.push_str(" language");
println!("{}", name);
}
Why this works:
first_borrowimmutably borrowsname- its last use is in
println! - after that line, the borrow is over
name.push_str(...)can then mutably usename
Another example with mutable borrowing:
fn main() {
let mut numbers = vec![, , ];
= &numbers[];
(, first);
numbers.();
}
Step by Step Execution
Consider this example:
fn main() {
let mut text = String::from("abc");
let r = &text;
println!("{}", r);
text.push('d');
println!("{}", text);
}
Step by step:
-
let mut text = String::from("abc");- A mutable
Stringnamedtextis created.
- A mutable
-
let r = &text;rbecomes an immutable reference totext.- While this borrow is active, Rust must prevent conflicting mutation.
-
println!("{}", r);ris used here.
Real World Use Cases
Non-lexical lifetimes help in many everyday Rust patterns.
Working with collections
You may read from a vector, map, or string, then modify it later in the same function.
fn main() {
let mut items = vec![10, 20, 30];
let first = &items[0];
println!("{}", first);
items.push(40);
}
Parsing and then updating state
A program may inspect some config or cached value through a reference, finish using it, and then mutate the underlying structure.
Web and API handlers
In server code, you often:
- read a field from request state
- log or validate it
- later mutate the same request/session/context object
NLL helps such code compile without extra temporary blocks.
CLI tools and scripts
A script may borrow part of a data structure for display, then reuse and modify the original structure later.
Error handling paths
Rust code often borrows data to inspect it during validation, then updates state only if validation succeeds. NLL reduces artificial borrow conflicts in this style of code.
Real Codebase Usage
In real projects, developers usually do not think, "Now I will use NLL." They simply write straightforward code, and the compiler accepts more of it than older Rust would.
Common patterns where NLL helps:
Guard-style checks before mutation
fn add_suffix_if_not_empty(s: &mut String) {
if s.is_empty() {
return;
}
s.push_str("!");
}
The compiler can reason more precisely about temporary borrows created during checks like s.is_empty().
Read, log, then mutate
fn process_user(name: &mut String) {
let view = &name[..];
println!("Processing {view}");
name.push_str(" (done)");
}
Validation before updates
fn ensure_and_add(values: &mut Vec<i32>) {
= values.();
(, len);
values.();
}
Common Mistakes
Mistake 1: Thinking NLL makes all borrow errors disappear
NLL is more flexible, but it does not permit unsafe code.
Broken example:
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0];
v.push(4);
println!("{}", first);
}
Why it fails:
firstis used afterpushpushmay move the vector's data- the reference could become invalid
Mistake 2: Confusing variable scope with borrow lifetime
A variable can remain in scope even after its borrow is no longer active.
fn main() {
let mut s = String::from("hi");
let = &s;
(, r);
s.();
}
Comparisons
| Concept | What it means | How it behaves |
|---|---|---|
| Lexical lifetime | Borrow tied closely to source-code block scope | Often more conservative |
| Non-lexical lifetime | Borrow ends at last actual use when possible | More precise and flexible |
| Variable scope | Where a variable name is accessible | Not the same as borrow lifetime |
| Explicit lifetime annotations | Relationships between references in APIs and types | Written by the programmer |
| NLL | Compiler's internal borrow analysis | Usually inferred automatically |
Another useful comparison:
| Situation | Before NLL | With NLL |
|---|---|---|
| Borrow a value, use it, then mutate original later in same block |
Cheat Sheet
Core idea
- Non-lexical lifetimes (NLL) let borrows end at their last use instead of always at the end of the surrounding block.
- This makes borrow checking more precise.
- Safety rules do not change.
Remember
- A variable can still be in scope after its borrow has ended.
- NLL is automatic; there is no special syntax.
- NLL does not allow mutation while a still-needed reference exists.
Typical pattern
let r = &value;
println!("{}", r); // last use of r
value_mutation(); // often allowed after this
Still not allowed
let r = &value;
value_mutation();
println!("{}", r); // r used after mutation
Good mental shortcut
- Old model: borrow lasts for the block
- NLL model: borrow lasts for the actual use
Related rules
- immutable borrows can coexist with other immutable borrows
- mutable borrows must be exclusive
- references must never outlive the data they point to
FAQ
What does non-lexical lifetime mean in Rust?
It means a borrow can end based on its last actual use, not only at the end of the surrounding code block.
Did Rust change its safety rules with NLL?
No. Rust remains just as strict about memory safety. NLL only makes the analysis more precise, so safe code is rejected less often.
Do I need to write special syntax for non-lexical lifetimes?
No. NLL is handled automatically by the compiler.
Are non-lexical lifetimes the same as lifetime annotations like 'a?
No. Lifetime annotations are written in function signatures and types. NLL is about how the compiler analyzes borrows in code.
Why did older Rust reject some code that now compiles?
Older borrow checking often assumed a borrow lasted until the end of the enclosing scope. NLL can detect that the borrow ended earlier.
Does NLL fix all borrow checker problems?
No. If code is truly unsafe or a reference is still used after conflicting access, Rust will still reject it.
When should I still use extra scopes or refactoring?
If borrows genuinely overlap in conflicting ways, you may still need to restructure code, copy small values, or split logic into smaller steps.
Mini Project
Description
Build a small Rust program that reads from a Vec<String>, prints one borrowed item, and then safely modifies the vector afterward. This demonstrates the practical effect of non-lexical lifetimes: a borrow can end after its last use, allowing later mutation in the same scope.
Goal
Create a program that borrows data from a vector, uses that borrow, and then mutates the original vector without borrow checker errors.
Requirements
- Create a mutable vector of strings.
- Borrow one element immutably and print it.
- After the last use of that borrow, add a new element to the vector.
- Print the final vector contents.
- Keep everything inside a single function.
Keep learning
Related questions
Accessing Cargo Package Metadata in Rust
Learn how to read Cargo package metadata like version, name, and authors in Rust using compile-time environment macros.
Associated Types vs Generic Type Parameters in Rust: When to Use Each
Learn when to use associated types vs generic parameters in Rust traits, with clear rules, examples, and practical API design advice.
Can a Struct Extend Another Struct in Rust? Composition vs Inheritance
Learn how Rust handles struct reuse without inheritance, using composition, traits, and wrapper structs with practical examples.