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![1, 2, 3];
let first = &numbers[0];
println!("first = {}", first);
numbers.push(4);
}
This works under NLL because the borrow of numbers[0] ends after println!. If the reference were still needed later, the push would not be allowed.
Example that still fails:
fn main() {
let mut numbers = vec![1, 2, 3];
let first = &numbers[0];
numbers.push(4);
println!("first = {}", first);
}
This is rejected because:
firstis still used afternumbers.push(4)pushmay reallocate the vector- that could invalidate the reference
NLL does not weaken Rust's safety rules. It only shortens borrows when it is provably safe to do so.
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.- This is the last use of
r.
-
After this line, NLL determines that the borrow is no longer needed.
- Even though
ris still in scope textually, its lifetime can end here.
- Even though
-
text.push('d');- Since the earlier immutable borrow has ended, Rust allows mutable access to
text.
- Since the earlier immutable borrow has ended, Rust allows mutable access to
-
println!("{}", text);- The updated string is printed.
Important detail:
- The variable
ris still in the block. - But its borrow lifetime has ended.
- This is exactly what makes the lifetime non-lexical.
The lifetime is based on usage, not simply on the variable's surrounding braces.
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>) {
let len = values.len();
println!("current length: {}", len);
values.push(99);
}
Match and conditional flows
NLL also helps the compiler understand that some borrows only exist on certain control-flow paths.
fn main() {
let mut s = String::from("hello");
if s.len() > 3 {
let r = &s;
println!("{}", r);
}
s.push('!');
}
In larger codebases, this means fewer unnecessary workarounds such as:
- introducing extra inner blocks just to end borrows early
- cloning values only to satisfy the borrow checker
- restructuring readable code into less natural forms
NLL does not remove the need to understand borrowing, but it makes idiomatic code easier to express.
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 r = &s;
println!("{}", r);
s.push('!');
}
r is still declared in the block, but its lifetime ends after its last use.
Mistake 3: Assuming every borrow ends exactly where you expect
The compiler ends borrows based on actual use. If a reference is used later, the borrow lives longer.
fn main() {
let mut s = String::from("hi");
let r = &s;
s.push('!'); // error
println!("{}", r);
}
Mistake 4: Believing explicit lifetime annotations are the same thing as NLL
Lifetime annotations like 'a describe relationships between references in function signatures or types. NLL is a compiler analysis feature for local borrow checking.
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() > b.len() { a } else { b }
}
This uses explicit lifetime annotations, but it is not itself an example of NLL.
Mistake 5: Using references when a copied value is simpler
Sometimes you do not need a borrow at all.
fn main() {
let mut numbers = vec![1, 2, 3];
let first = numbers[0]; // copy i32, no borrow kept alive
numbers.push(4);
println!("{}", first);
}
For small Copy types like i32, copying can avoid borrow conflicts entirely.
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 | Often rejected | Often accepted |
| Borrow is still used after mutation | Rejected | Still rejected |
| Need for extra inner scopes to end borrow early | More common | Less common |
In short:
- Scope answers: where is the variable visible?
- Lifetime answers: how long is the reference valid?
- NLL improves how Rust computes that validity.
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
When NLL helps most
- reading then mutating in the same function
- collection access followed by updates
- cleaner control-flow without extra braces
When it cannot help
- if a reference is used after a conflicting mutation
- if data could be moved or reallocated while a reference still exists
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
Absolute Values in Rust: Using the abs() Method
Learn how to calculate absolute values in modern Rust with abs(), handle signed integer overflow, and choose safer alternatives.
Accepting Iterators in Rust Functions with impl Iterator
Learn how to write Rust functions that accept HashMap iterators using generic bounds or impl Iterator, avoiding trait object and lifetime errors.
Access Enum Variant Values in Rust with Pattern Matching
Learn how to extract and print values stored in Rust enum variants using match and if let pattern matching, with practical examples.