Intro to Embedded Rust Part 8: Lifetimes and Lifetime Annotations
2026-03-12 | By ShawnHymel
Lifetimes are one of Rust's more challenging concepts, but they're essential for understanding how the borrow checker ensures memory safety. In this tutorial, we’ll discuss the concept of lifetimes and demonstrate a few ways in which you can help the compiler (and borrow checker) understand your intentions with references by using lifetime annotations.
Note that all code for this series can be found in this GitHub repository.
Lifetimes
A lifetime is a measure of how long a reference remains valid. In other words, it is the scope during which the data being referenced actually exists in memory. In many cases, the Rust compiler can automatically infer lifetimes through a process called "lifetime elision," so you often don't need to write them explicitly. However, when the compiler encounters ambiguous situations (e.g., a function that takes multiple references as parameters and returns a reference), it can't determine which input the returned reference is tied to, and that's when you need lifetime annotations. These annotations don't change how long values actually live; they simply help the compiler verify that references won't outlive the data they point to, preventing dangerous bugs like use-after-free and dangling pointers that plague C and C++ programs.
To understand lifetimes, let's examine a code snippet that demonstrates what the borrow checker prevents.

In this example, we declare a variable r in the outer scope, which we can annotate with a lifetime 'a. Inside a new inner scope (created with curly braces), we create a variable x with the value 5. This inner scope has a shorter lifetime, we can call 'b. We then try to assign r as a reference to x, making r point to data that lives in the inner scope.
The problem occurs when the inner scope ends: x goes out of scope and is dropped, freeing its memory. Now r is a dangling pointer (a reference pointing to invalid memory that no longer exists). If we tried to use r by printing it, we'd be attempting to access freed memory, which could lead to crashes, data corruption, or security vulnerabilities in languages like C or C++. However, this code won't compile in Rust because the borrow checker recognizes that the lifetime of the reference ('b, tied to the inner scope where x lives) is shorter than the lifetime of r itself ('a, which extends to the outer scope). The compiler rejects this code with an error like "x does not live long enough," preventing the dangling pointer before the program ever runs.
Now, let’s look at another example:
struct SensorReading {
value: u16,
timestamp_ms: u32,
}
fn add_one(reading: &mut SensorReading) -> &mut SensorReading {
reading.value += 1;
reading
}
fn main() {
let mut reading = SensorReading{ value: 1, timestamp_ms: 101 };
let another_ref = add_one(&mut reading);
println!("{}, {}", another_ref.value, another_ref.timestamp_ms);
}
When the compiler analyzes the add_one() function call in main(), it treats the function as a black box. In other words, it only looks at the function signature, not the implementation details inside. From main()'s perspective, when it sees let another_ref = add_one(&mut reading), it knows that add_one() takes a mutable reference and returns a mutable reference, but it doesn't know what that returned reference points to. The compiler doesn't dive into the function body to trace that the return value is actually the same reference that was passed in. For all the compiler knows from just the signature, add_one() could be creating some local variable inside the function and returning a reference to it, which would be invalid once the function returns and that local variable is dropped.
Without something to help the compiler understand the intended lifetime (that the return value is a reference with the same lifetime as the reading parameter), the compiler can't determine the relationship between the input reference's lifetime and the output reference's lifetime. Is the returned reference tied to the input parameter's lifetime? Or is it referencing something else entirely?
This is where lifetime annotations come into play: they explicitly document the relationship between input and output lifetimes, allowing the compiler to verify that another_ref in main() won't outlive the data it references.
Example 1: Lifetime Annotations and Elision
Let’s rewrite add_one() with an explicit lifetime annotation:
fn add_one<'a>(reading: &'a mut SensorReading) -> &'a mut SensorReading {
reading.value += 1;
reading
}
Lifetime annotations use apostrophe syntax like 'a (pronounced "tick a") to label the lifetimes of references in function signatures, struct definitions, and method implementations. When you write &'a SensorReading, you're telling the compiler "this is a reference to a SensorReading that lives for lifetime 'a." The compiler then uses these annotations to ensure that no reference outlives the data it points to.
While this might seem like extra complexity compared to languages with garbage collection, lifetime annotations are Rust's zero-cost way of guaranteeing memory safety at compile time. There's no runtime overhead, no garbage collector pausing your program, and no possibility of accessing freed memory. In embedded systems where resources are limited and reliability is critical, this compile-time safety without runtime cost is invaluable. You'll encounter lifetime annotations throughout the embedded Rust ecosystem, especially in HAL crates and driver libraries, so understanding how they work will help you both use and write better embedded code.
However, in this particular case, the compiler can use lifetime elision rules to automatically infer the annotations, but understanding why they're conceptually needed helps you recognize when you must write them explicitly. Our original version of add_one() would compile just fine, because the compiler uses elision to infer the lifetimes of the parameter and returned reference:
fn add_one(reading: &mut SensorReading) -> &mut SensorReading {
reading.value += 1;
reading
}
In many common patterns, the Rust compiler can automatically infer lifetimes without requiring explicit annotations through a process called "lifetime elision." The elision rules apply primarily to functions: if there's only one input reference parameter, the compiler assumes any output references have the same lifetime as that input. For methods (functions with &self or &mut self), the compiler assumes all output references are tied to the lifetime of self. These rules cover the majority of everyday cases, which is why you often don't see lifetime annotations in simple code, but when functions have multiple reference parameters or more complex relationships, you must annotate them explicitly to resolve ambiguity.
You can read more about the elision rules in this section of 10.3 in the Rust Book.
Example 2: Required Function Lifetime Annotations
Let’s look at an example where lifetime annotations are required for a function signature:
// Error: missing lifetime specifier
// fn highest_val(r1: &SensorReading, r2: &SensorReading) -> &SensorReading {
// if r1.value > r2.value {
// r1
// } else {
// r2
// }
// }
// Fix: provide lifetime annotations
fn highest_val<'a>(r1: &'a SensorReading, r2: &'a SensorReading) -> &'a SensorReading {
if r1.value > r2.value {
r1
} else {
r2
}
}
fn demo_highest() {
let reading_1 = SensorReading{ value: 5, timestamp_ms: 102 };
let reading_2 = SensorReading{ value: 10, timestamp_ms: 102 };
let highest = highest_val(&reading_1, &reading_2);
println!("Highest: {}, {}", highest.value, highest.timestamp_ms);
}
The highest_val() function demonstrates a situation where lifetime annotations are mandatory because the elision rules don't apply. The function takes two input references (r1 and r2) and returns one reference, but the compiler can't automatically determine which input the output is tied to: it could be returning r1, r2, or theoretically a reference to something else entirely.
Without explicit annotations, the compiler produces an error: "missing lifetime specifier." By adding the lifetime parameter <'a> and annotating all three references with 'a, we're telling the compiler: "both input references must live for at least lifetime 'a, and the returned reference will also be valid for lifetime 'a." This means the returned reference will be valid as long as both inputs are valid.
In main(), when we call highest_val(&reading_1, &reading_2), the compiler determines that lifetime 'a is the shorter of the two input lifetimes (the overlap period where both reading_1 and reading_2 are valid). The variable highest can then safely hold this reference because the compiler has verified it won't outlive either of the original values. This annotation doesn't change the actual lifetimes of any variables. It simply provides the compiler with enough information to verify that the function returns a reference that's guaranteed to be valid for as long as the caller needs it. Without these annotations, the compiler would reject the code to prevent potential dangling pointer bugs.
Example 3: Struct Lifetime Annotations
Lifetime annotations can be used for structs as well as functions:
// Error: missing lifetime specifier
// struct SampleHolder {
// sample: &SensorReading,
// }
// Fix: add a lifetime annotation
struct SampleHolder<'a> {
sample: &'a SensorReading,
}
fn demo_struct_ref() {
let reading = SensorReading{ value: 11, timestamp_ms: 103 };
let holder = SampleHolder{ sample: &reading };
println!("{}, {}", holder.sample.value, holder.sample.timestamp_ms);
}
When a struct contains references (rather than owned data), you must add lifetime annotations to indicate how long those references remain valid. The SampleHolder struct holds a reference to a SensorReading, which means the struct itself doesn't own the data, as it's borrowing it from somewhere else.
Without the lifetime annotation <'a>, the compiler can't verify that the referenced data will outlive instances of the struct, potentially leading to dangling pointers. By declaring struct SampleHolder<'a> and annotating the field as sample: &'a SensorReading, we're telling the compiler that any instance of SampleHolder cannot outlive the SensorReading it references.
In main(), when we create holder with a reference to reading, the compiler ensures that holder can only exist as long as reading is in scope. If we tried to return holder from the function while reading was a local variable, the compiler would reject it because holder would outlive the data it references. This is a common pattern in embedded Rust drivers where structs hold references to peripheral configurations or hardware resources. The lifetime annotations ensure that the driver can't outlive the resources it depends on.
Example 4: Method Annotations
In addition to basic functions, we can also annotate lifetimes for methods attached to structs and enums:
struct SensorBuffer<'a> {
name: &'a str,
readings: &'a [SensorReading],
}
// Error: implicit elided lifetime not allowed here
// impl SensorBuffer {
// fn new(name: & str, readings: &[SensorReading]) -> Self {
// SensorBuffer { name, readings }
// }
// fn get_latest(&self) -> &SensorReading {
// &self.readings[self.readings.len() - 1]
// }
// }
// Fix: annotate
impl<'a> SensorBuffer<'a> {
fn new(name: &'a str, readings: &'a [SensorReading]) -> Self {
SensorBuffer { name, readings }
}
fn get_latest(&self) -> &'a SensorReading {
&self.readings[self.readings.len() - 1]
}
}
fn demo_methods() {
let readings = [
SensorReading{ value: 23, timestamp_ms: 104},
SensorReading{ value: 25, timestamp_ms: 204},
SensorReading{ value: 21, timestamp_ms: 304},
];
let buffer = SensorBuffer::new("My Readings", &readings);
let latest = buffer.get_latest();
println!("{} latest: {}, {}", buffer.name, latest.value, latest.timestamp_ms);
}
When implementing methods for a struct (or enums) that has lifetime parameters, you must declare those lifetime parameters in the impl block as well. The SensorBuffer<'a> struct contains two references (a string slice for the name and a slice of sensor readings). Both are annotated with lifetime 'a. When we write impl<'a> SensorBuffer<'a>, we're declaring that the implementation applies to SensorBuffer with lifetime parameter 'a, and all methods within this block can reference that lifetime.
The new() constructor explicitly annotates its parameters with 'a to indicate that both the name and readings references must live at least as long as the SensorBuffer instance being created. The get_latest() method returns a reference with lifetime 'a rather than relying on elision, making it explicit that the returned reference is tied to the lifetime of the data stored in the struct, not just the lifetime of the &self borrow.
In main(), the lifetime relationships ensure safety: the readings array owns the actual data, buffer holds references to that data with lifetime tied to readings, and latest is a reference into the readings slice. The compiler verifies that readings outlives both buffer and latest, preventing any possibility of accessing freed memory.
This pattern is extremely common in embedded Rust, especially in HAL implementations where buffer structs might hold references to DMA memory regions, peripheral configuration data, or other hardware resources. The lifetime annotations guarantee that these structs can't outlive the underlying hardware resources they reference, preventing subtle bugs that could cause hardware malfunctions or system crashes.
Example 5: Static Lifetimes
The special 'static lifetime is a special lifetime annotation that indicates data lives for the entire duration of the program. It's valid from startup until the program terminates. Let’s look at a simple example:
// Works, but not clear: compiler infers that 'a is 'static
// struct SensorConfig<'a> {
// name: &'a str,
// units: &'a str,
// }
// Fix: add static lifetime annotation when we know the data lives forever
struct SensorConfig {
name: &'static str,
units: &'static str,
}
// const: inline at every use, static: one instance in memory
static TEMP_SENSOR_CONFIG: SensorConfig = SensorConfig {
name: "Temperature Sensor",
units: "C"
};
fn demo_static() {
println!("{}, units: {}", TEMP_SENSOR_CONFIG.name, TEMP_SENSOR_CONFIG.units);
}
String literals like "Temperature Sensor" are stored directly in the program's binary and have a 'static lifetime because they exist for the program's entire execution. When we know that all references in a struct will be 'static, we can explicitly annotate them as &'static str rather than using a generic lifetime parameter like &'a str. This makes the intent clearer: this struct will only hold references to data that lives forever, not temporary data that might go out of scope.
The commented-out version with <'a> would technically work (the compiler could infer that 'a must be 'static based on how we use it), but explicitly writing 'static is more self-documenting and removes any ambiguity.
The static TEMP_SENSOR_CONFIG declaration creates a global variable with one instance stored in memory that exists for the program's lifetime, which is different from const variables that get inlined at every use site. This pattern is common in embedded systems for configuration data, lookup tables, and peripheral definitions that need to persist throughout the program's execution.
In no-std embedded environments, 'static lifetimes are particularly important because they allow you to store configuration data in flash memory rather than precious RAM, and they enable patterns like storing references to hardware peripherals in global variables that interrupt handlers can safely access. You'll frequently see 'static in embedded Rust for things like pre-computed sine wave tables, device descriptors, or any data that's known at compile time and needs to live for the entire program execution.
Recommended Reading
Hopefully, this helps you get a handle on lifetime annotations. They are extremely common throughout various Rust libraries. In the next tutorial, we’ll look at test-driven development and how to write unit tests for our I2C TMP102 driver crate. I recommend reading chapter 11 in the Rust Book as well as tackling the test exercises in rustlings.
Find the full Intro to Embedded Rust series here.

