Module 12: Lifetimes in Rust
Lecture 29: Friday, April 10, 2026.
This module is based on earlier notes by Tom Gardos and Lauren Wheelock
This module introduces Rust’s lifetime system, which ensures memory safety by tracking how long references remain valid. We’ll explore lifetime annotations, the borrow checker, lifetime elision rules, and how lifetimes work with functions, structs, and methods.
By the end of this module, you should be able to:
- Understand how the borrow checker prevents dangling references
- Write explicit lifetime annotations when required by the compiler
- Apply lifetime elision rules to understand when annotations are optional
- Use lifetimes in function signatures, structs, and methods
- Combine lifetimes with generics and trait bounds
- Debug lifetime-related compilation errors effectively
Background Readings
Read the following sections from “The Rust Programming Language” book:
Lifetimes Overview
Lifetimes ensure that references are valid for as long as the code needs them to be. Specifically, their goal is to allow the Rust compiler to detect and prevent dangling references.
Note: you can separate declaration and initialization in Rust. We will use this feature in these notes to illustrate lifetimes.
fn main() { let r; // declaration r = 32; // initialization println!("r: {r}"); }
The Rust Compiler Borrow Checker
Let’s start with the following code:
fn main() { let r; { let x = 5; r = &x; } println!("r: {r}"); }
Before running the code, think about the following questions:
- In which lines of code is
ralive? I.e., where canrbe used?- Think about what the scope of
ris.
- Think about what the scope of
- In which lines of code is
xalive? (or what is its scope!) - What happens after
xgoes out of scope? - Should Rust allow this program to compile and run? What bad thing might happen if it did?
Feel free to use aquascope to interpret the program to answer question 4!
Let’s annotate the sections of code where r and x are alive. In Rust terminology, this is called a Lifetime! Rust uses a special naming pattern for lifetimes: 'a (single quote followed by identifier)
fn main() { let r; // ---------+-- 'a // | { // | let x = 5; // -+-- 'b | r = &x; // | | } // -+ | // | println!("r: {r}"); // | } // ---------+
In this case, r’s scope is the entirety of the main function as represented by lifetime 'a.
On the other hand, x’s scope is the nested curly braces represented by lifetime 'b.
We can see that 'b is much shorter than 'a. Meaning that r might be used after x goes out of scope (and gets destroyed!). Meaning that r becomes a dangling reference!
This is why Rust does not allow this program to compile! You can confirm this by running the program and observing the output.
This gives us the golden rule of lifetimes in Rust: You cannot assign something with a shorter lifetime to something that outlives it!
The direction is important: assigning something with a short lifetime (x, 'b) to a longer lifetime (r, 'a) risks dangling references. The other way around is OK: a reference that expires before the data it refers to expires is not dangerous!
We can fix the above code rewriting it so that the lifetime 'a survives as long as 'b.
fn main() { let r; // ----------+-- 'a // | let x = 5; // --+-- 'b | // | | r = &x; // | | // | | println!("r: {r}"); // | | // | | } // --+-------+
Now, both x and r are in scope until the end of the main function, and thus their lifetimes 'b and 'a expire at the same time.
Generic Lifetimes in Functions
In the above example, we used lifetimes as comments to annotate where variables go out of scope. This was a conceptual tool to help us understand what was going on. We did not need to actually specify these lifetimes to Rust in the code: the Rust borrow checker automatically identifies and reasons about them.
Now, let’s see an example of why we need to be able to specify lifetimes.
Say we want to build a function to compare two strings and return a reference to the longest one. It makes sense to pass these strings by references, since we do not need to own them in this function.
// compare two string slices and return reference to the longest fn longest(x: &String, y: &String) -> &String { if x.len() > y.len() { return x; } else { return y; } } fn main() { let string1 = String::from("abcd"); let string2 = String::from("xyz"); let result = longest(&string1, &string2); println!("The longest string is {result}"); }
Run the code and you will notice that we have a compile time error! Read the error carefully. What is the problem?
The problem is the compiler is unable to know for certain what the reference that longest returns
refers to. It might refer to x, it might also refer to y. As a result, it cannot know what its
lifetime is in general!
In part, this happens because the compiler analyzes the function longest() in isolation, without looking at how main uses it – this is important, as a function might be used many times in different ways and in different places! So, Rust wants to make sure it is safe in isolation and under any circumstances!
Explicitly Annotating Lifetimes in Functions
We can fix this by explicitly annotating the lifetimes of the parameters. Let’s start with annotating each parameter with its own unique different lifetime.
We have to decide which lifetime to annotate the return value with. This is a catch 22, the compiler would not be happy with either choices.
// compare two string slices and return reference to the longest fn longest<'a, 'b>(x: &'a String, y: &'b String) -> &'a String { if x.len() > y.len() { return x; } else { return y; } }
Try to run the above code: the compiler will be OK with the line that returns x, since the lifetime of x is 'a, which matches the return type. However, it will be produce a compile error for the line that returns y, since its lifetime 'b does not match the return type of the function &'a String.
Edit the code so that the return type is &'b String and run it. You will see that now the compiler produces an error in the flip side.
The only way forward is to annotate x, y, and the return type with the same lifetime!
// compare two string slices and return reference to the longest fn longest<'a>(x: &'a String, y: &'a String) -> &'a String { if x.len() > y.len() { return x; } else { return y; } } fn main() { let string1 = String::from("abcd"); let string2 = String::from("xyz"); let result = longest(&string1, &string2); println!("The longest string is {result}"); }
You can run the code above and confirms that it indeed works! It is easy to see how since both string1 and string2 live the same duration in the main function – both of their scopes is the rest of the main function!
Detour: Lifetimes and References Syntax
Names of lifetime parameters must start with an apostrophe (’) and are usually all lowercase and very short, like generic types
&i32 // a reference with inferred lifetime
&'a i32 // a reference with an explicit lifetime
&'a mut i32 // a mutable reference with an explicit lifetime
Detour: The Static Lifetime
'static is a special lifetime designation that represents values that live for the
entire duration of the program.
This mostly applies to constants (such as constant strings) and certain heap-allocated values.
#![allow(unused)] fn main() { let s: &'static str = "I have a static lifetime."; }
While the temptation of using 'static may be strong (since it lives long enough for any trait bound!), avoid using it when you can use more fine grained lifetimes!
For more, see for example:
- https://doc.rust-lang.org/rust-by-example/scope/lifetime/static_lifetime.html
Back from Detour: What if the Lifetimes are not equal?
Using a single lifetime in longest may make sense when looking at the function in isolation.
But, what if when we call the function, we provide two parameters with different lifetimes?
// compare two string slices and return reference to the longest fn longest<'a>(x: &'a String, y: &'a String) -> &'a String { if x.len() > y.len() { return x; } else { return y; } } fn main() { let s1 = String::from("abcd"); // ----------+-- 'l1 { // | let s2 = String::from("xyz"); // --+-- 'l2 | // | | let result = longest(&s1, &s2); // | | println!("{}", result); // | | // --+ | } // | // ----------+ }
Now, s1 and s2 have lifetimes 'l1 and 'l2, and 'l1 outlives 'l2. Yet, surprisingly, Rust
accepts and runs this code with no problems!
This is because Rust allows assigning things with a longer lifetime to shorter lifetimes!
Specifically, Rust selects the shorter of the two lifetimes, 'l2, and automatically assigns it
to 'a when calling longest.
This is safe – it means that longest believes that x lives less than it actually does, which will not cause any dangling references!
Consider what this means for result: longest returns a reference with lifetime 'a, and since Rust sets 'a to the shorter of the two lifetimes, then result has lifetime 'l2.
We can confirm this by attempting to use result past 'l2 as below:
// compare two string slices and return reference to the longest fn longest<'a>(x: &'a String, y: &'a String) -> &'a String { if x.len() > y.len() { return x; } else { return y; } } fn main() { let s1 = String::from("abcd"); // ----------+-- 'l1 let result; // | { // | let s2 = String::from("xyz"); // --+-- 'l2 | result = longest(&s1, &s2); // | | // --+ | } // | println!("{}", result); // | // ----------+ }
Notice that the Rust borrow checker produces a compile-time error for the above program. Specifically, the error identifies that s2 does not live long enough (i.e., that ’l2 is too short!).
Lifetime of return type must match lifetime of at least one parameter
What do you think would happen if we ask Rust to run this code?
#![allow(unused)] fn main() { fn longest<'a, 'b, 'c>(x: &'a String, y: &'b String) -> &'c String { let result = String::from("really long string"); return &result; } }
Compile error!
The returned reference refers to result, but result only lives inside longest, and goes out
of scope (and thus gets dropped or destroyed) at the end of the function!
So, a function that returns a reference must refer to one of its parameters, or something deduced from those parameters (and thus must have a matching lifetime), since all other variables and data created within the function will be destroyed at the end of the function!
Lifetime Annotations in Struct Definitions
So far, we’ve only used structs that fully owned their member types. We can also define structs to hold references, but then we need lifetime annotations.
#[derive(Debug)] struct ImportantElement<'a> { element: &'a String, index: usize } fn main() { let strings = vec![String::from("string1"), String::from("string2")]; let e = ImportantElement { element: &strings[0], index: 0 }; println!("{:?}", e); }
We need the explicit lifetime annotation because we need to help the compiler understand how long the fields inside the struct can live, which in turn governs how long instances of that struct can live.
For example, in the code above, e stores a reference element to one of the strings.
This means that e.element cannot live longer than strings, meaning that e itself cannot
live longer than strings!
We can confirm this by looking at the code below:
#[derive(Debug)] struct ImportantElement<'a> { element: &'a String, index: usize } fn main() { let e; // ----------+-- 'l1 { // | let strings = vec![ // --+-- 'l2 | String::from("string1"), // | | String::from("string2"), // | | ]; // | | e = ImportantElement { // | | element: &strings[0], // | | index: 0 // | | }; // | | // --+ | } // | println!("{:?}", e); // | // ----------+ }
Rust produces a compile error for this code. The borrow checker knows that
e must have lifetime 'l1, so its type must be ImportantElement<'l1>.
However, when we assign a value to e, we provide it a reference to strings[0],
whose lifetime, 'l2, lives less than l1!
Lifetime Elision
In Rust, the cases where we can omit lifetime annotations are called lifetime elision.
e·li·sion
/əˈliZH(ə)n/
noun
the omission of a sound or syllable when speaking (as in I'm, let's, e ' en ).
* an omission of a passage in a book, speech, or film.
"the movie's elisions and distortions have been carefully thought out"
* the process of joining together or merging things, especially abstract ideas.
"unease at the elision of so many vital questions"
Here is a simple example of Lifetime elision. Look at this code. Does it compiler? Confirm by running it!
fn first_element(v: &Vec<String>) -> &String { return &v[0]; }
How come that code compiles? Shouldn’t we have to write out the lifetimes explicitly as below?
fn first_element<'a>(v: &'a Vec<String>) -> &'a String { return &v[0]; }
The answer is Lifetime elision! The compiler was able to automatically infer the lifetime annotations in this example.
Inferring Lifetimes: When can Lifetimes be elided?
The compiler developers decided that some patterns were so common and simple to infer that the compiler could just infer and automatically generate the lifetime specifications.
Pattern 1
Compiler assigns a unique lifetime parameter to each parameter that is a reference.
So:
// function with one parameter
fn foo(x: &i32);
// a function with two parameters
fn foo(x: &i32, y: &i32);
// and so on.
would automatically become:
// function with one parameter gets a lifetime parameter
fn foo<'a>(x: &'a i32);
//a function with two parameters gets two separate lifetime parameters:
fn foo<'a, 'b>(x: &'a i32, y: &'b i32);
// and so on.
Pattern 2
If there is exactly one input lifetime parameter, that lifetime is assigned to all output lifetime parameters
So:
fn foo(x: &i32) -> &i32
would automatically become:
fn foo<'a>(x: &'a i32) -> &'a i32
Pattern 3
If there are multiple input lifetime parameters, but one of them is &self or &mut self (because this is a method on a struct / type), the lifetime of self is assigned to all output lifetime parameters.
So:
struct MyType { /* .. */ }
impl MyType {
fn foo(&self, x: &i32, y: &i32) -> &i32 { /* ... */ }
}
would automatically become:
struct MyType { /* .. */ }
impl MyType {
fn foo<'a, 'b, 'c>(&'a self, x: &'b i32, y: &'c i32) -> &'a i32 { /* ... */ }
}
Let’s Test Our Understanding
You’re the compiler and you see this function.
fn first_word(s: &str) -> &str {...}
Do any rules apply? which one would you apply first?
Answer:
First rule: Apply input lifetime annotations.
fn first_word<'a>(s: &'a str) -> &str {...}
Second rule: Apply output lifetime annotation.
fn first_word<'a>(s: &'a str) -> &'a str {...}
Done! Everything is accounted for.
Test Our Understanding Again
What about if you see this function signature?
fn longest(x: &str, y: &str) -> &str {...}
Can we apply any rules?
We can apply first rule again. Each parameter gets it’s own lifetime.
fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {...}
Can we apply anymore rules?
No! Produce a compiler error asking for annotations.
Recap
- Lifetimes are a way to ensure that references are valid as long as we need them to be.
- The borrow checker is a tool that helps us ensure that our references are valid.
- We can use lifetime annotations to help the borrow checker understand our code better.
- We can use lifetime elision to help the compiler infer lifetimes for us.
- We can use lifetimes in function signatures, structs, and methods.
- We can combine lifetimes with generics and trait bounds.
Exercises
To prepare for our final exam, make sure you solve these exercises as practice!
Feel free to use the Rust playground or VSCode! Only look at the solutions after you write down your answers to confirm them and identify your mistakes.
Exercise 1 – Illustrate the Lifetimes
Annotate the lifetimes of the variables in the following code using the notation from the beginning of the module.
#![allow(unused)] fn main() { { let s = String::from("never mind how long precisely --"); // { // let t = String::from("Some years ago -- "); // { // let v = String::from("Call me Ishmael."); // println!("{v}"); // } // println!("{t}"); // } // println!("{s}"); // } // }
Solution
#![allow(unused)] fn main() { { let s = String::from("never mind how long precisely --"); //----------+'a { // | let t = String::from("Some years ago -- "); //------+'b | { // | | let v = String::from("Call me Ishmael."); //--+'c | | println!("{v}"); // | | | } //--+ | | println!("{t}"); // | | } //--------+ | println!("{s}"); // | } //----------+ }
Exercise 2 – Fix the Function with Multiple References
The following function is supposed to take a vector of strings, a default value, and an index, and return either the string at the given index or the default if the index is out of bounds. However, it won’t compile without lifetime annotations.
Add the appropriate lifetime annotations to make this code compile.
fn get_or_default(strings: &Vec<String>, default: &String, index: usize) -> &String { if index < strings.len() { return &strings[index]; } else { return default; } } fn main() { let vec = vec![String::from("hello"), String::from("world")]; let default = String::from("not found"); let result = get_or_default(&vec, &default, 5); println!("{}", result); }
Solution
fn get_or_default<'a>(strings: &'a Vec<String>, default: &'a String, index: usize) -> &'a String { if index < strings.len() { return &strings[index]; } else { return default; } } fn main() { let vec = vec![String::from("hello"), String::from("world")]; let default = String::from("not found"); let result = get_or_default(&vec, &default, 5); println!("{}", result); }
The return value could come from either strings or default, so both need the same
lifetime annotation 'a. The vector reference itself doesn’t need to live as long since
we’re returning references to its contents, not the vector itself.
Exercise 3 – Generic Type with Lifetime Annotations
The following code defines a Wrapper struct that holds both a generic value and a
reference. The struct and its method won’t compile without proper lifetime annotations.
Add the appropriate lifetime annotations to make this code compile.
struct Wrapper<T> { value: T, description: &String, } impl<T> Wrapper<T> { fn new(value: T, description: &String) -> Self { return Wrapper { value, description }; } fn get_description(&self) -> &String { return &self.description; } fn get_value(&self) -> &T { return &self.value; } } fn main() { let desc = String::from("A number"); let wrapper = Wrapper::new(42, &desc); println!("Value: {}, Description: {}", wrapper.get_value(), wrapper.get_description()); }
Solution
struct Wrapper<'a, T> { value: T, description: &'a String, } impl<'a, T> Wrapper<'a, T> { fn new(value: T, description: &'a String) -> Self { return Wrapper { value, description }; } fn get_description(&self) -> &String { return &self.description; } fn get_value(&self) -> &T { return &self.value; } } fn main() { let desc = String::from("A number"); let wrapper = Wrapper::new(42, &desc); println!("Value: {}, Description: {}", wrapper.get_value(), wrapper.get_description()); }
The struct needs a lifetime parameter 'a because it holds a reference (description).
The impl block must also declare this lifetime parameter: impl<'a, T>. The methods
get_description and get_value don’t need explicit lifetime annotations because the
compiler can apply elision rules (the return lifetime is inferred from &self).