Lifetimes in Struct Definitions
Understanding lifetime annotations when structs hold references - why they are required, how to write them, and practical patterns for single and multiple lifetime parameters
When a struct stores references as fields, the compiler cannot guess how long those references remain valid after the struct is created. Without explicit lifetime annotations, Rust will refuse to compile the definition. This section walks through the syntax and reasoning behind lifetime parameters on structs, moving from a single common lifetime to independent parameters that decouple unrelated borrows.
Why a Struct with References Needs a Lifetime
A struct that owns all its data (e.g., String, i32, Vec<T>) does not care about lifetimes. But when a field is a reference, the struct is borrowing data from somewhere else. That borrowed data must outlive any use of the struct field. The borrow checker needs to know the relationship between the struct’s lifetime and the references inside it.
Attempting to write a struct with a plain reference fails immediately:
struct Highlight {
text: &str, // error[E0106]: missing lifetime specifier
start: usize,
}
The compiler cannot tell whether the &str refers to data that will live longer than the Highlight instance. By adding a lifetime parameter, you make an explicit contract: the struct instance cannot exist after the referenced data goes out of scope.
struct Highlight<'a> {
text: &'a str,
start: usize,
}
Here, 'a is a generic lifetime parameter declared on the struct. It says “any Highlight<'a> must live at most as long as the string slice it points to.” The parameter does not name a concrete scope; it is a placeholder that the borrow checker substitutes at compile time with the actual lifetime of the borrowed data.
Compilation Guarantee:
Once you add 'a, the compiler will automatically reject any attempt to let the struct instance outlive the referenced string. You don't need to track this manually—the borrow checker enforces it.
Lifetime Annotation Syntax for Structs
The syntax mirrors generic type parameters but uses a leading apostrophe. The lifetime parameter appears in angle brackets after the struct name and is then used on every reference field that shares that lifetime.
struct Excerpt<'a> {
snippet: &'a str,
source: &'a str,
}
Both fields are tied to the same lifetime 'a. That means any Excerpt instance is valid only within a scope where both the snippet and source data remain alive. If the two references come from different places with different lifetimes, the compiler picks the shorter lifetime—the intersection—as the concrete value for 'a.
A struct can mix owned fields and borrowed fields freely:
struct LabeledValue<'a> {
label: &'a str,
value: i32,
count: u64,
}
Owned fields like i32 and u64 have no lifetime annotation because they are not borrowed. The struct’s lifetime is determined solely by the references it holds.
How a Single Lifetime Parameter Constrains the Struct
When every reference field shares the same 'a, the struct as a whole cannot outlive the shortest of those references. This is both a safety guarantee and a design restriction. Consider this pattern:
struct Pair<'a> {
first: &'a i32,
second: &'a i32,
}
fn main() {
let x = 10;
let pair;
{
let y = 20;
pair = Pair { first: &x, second: &y };
}
// println!("{}", pair.first); // error: `y` does not live long enough
}
x lives for the entire main function, but y only lives inside the inner block. The struct’s 'a must be valid for both references, so it gets constrained to the inner block’s scope. After y goes out of scope, the entire pair becomes invalid—even though we might only want to access first, which still points to live data. The compiler rejects any use of pair after the inner block because the struct carries the short lifetime as part of its type.
Overly Restrictive Single Lifetime:
Tying all references to a single 'a makes the struct's validity as short as the shortest-lived reference it contains, even if you never access the short-lived field again. If fields are independent, use separate lifetimes instead.
Think of the struct as a paper with a sticky note attached. The note says “valid until ____.” The blank is filled in with the earliest expiration date among the borrowed items. You cannot use the paper after that date, even if some information on it would still be fresh.
Multiple Lifetime Parameters for Independent References
When a struct holds references that do not need to die together, give each one its own lifetime parameter. This decouples their constraints and lets you keep using the struct as long as each field is accessed only while its specific borrow is still valid.
struct Pair<'a, 'b> {
first: &'a i32,
second: &'b i32,
}
Now first’s lifetime 'a and second’s lifetime 'b are completely independent. The earlier example compiles unchanged:
fn main() {
let x = 10;
let pair;
{
let y = 20;
pair = Pair { first: &x, second: &y };
// You can use both fields here.
println!("{} {}", pair.first, pair.second);
}
// `y` is gone, so pair.second is invalid, but pair.first is still live.
println!("{}", pair.first); // compiles
}
The compiler tracks each field separately. Accessing pair.first is allowed because 'a is still active. Accessing pair.second after the inner block would be caught and rejected, but you are not doing that. The struct itself does not have a single expiration stamp; it is alive as long as you stick to fields whose borrows are still live.
This independence is essential for structures that gather references with different origins—for example, a configuration struct that borrows a file path from the command line arguments and a settings string from an environment variable.
Lifetimes Are Part of the Type:
A Pair<'a, 'b> is a different type from Pair<'c, 'd>, just as Vec<i32> is different from Vec<String>. The compiler monomorphizes (generates a concrete version) for each combination of lifetimes at compile time, but you normally don't notice because the borrow checker works entirely with these lifetime variables.
Implementing Methods on Structs with Lifetimes
Methods on a struct with lifetime parameters must carry those parameters into the impl block. Even if a method doesn’t use the lifetime directly, the impl block itself must declare the generic lifetime parameters.
impl<'a> Excerpt<'a> {
fn display(&self) -> String {
format!("{} (from {})", self.snippet, self.source)
}
fn snippet_length(&self) -> usize {
self.snippet.len()
}
}
Here, impl<'a> introduces the lifetime parameter for the block, and Excerpt<'a> ties it to the struct. Methods that take &self or &mut self do not need to repeat the lifetime annotation because Rust infers that the borrow of self lives at least as long as 'a. But you can be explicit if needed.
When a method borrows self with a different lifetime than the struct’s fields, you can use separate lifetime parameters:
impl<'a> Excerpt<'a> {
fn compare_to<'b>(&self, other: &'b str) -> bool
where
'a: 'b,
{
self.snippet.len() == other.len()
}
}
The bound 'a: 'b says “the struct’s data lives at least as long as the comparison string.” This is typical when you want to ensure that self remains valid while the method operates.
Omitting Lifetime on impl:
Forgetting to write impl<'a> when the struct has a lifetime parameter leads to a compile error. Rust will not infer the lifetime because the impl block is where you provide the concrete implementations, and the lifetimes must be visible.
Common Mistakes When Using Lifetimes in Structs
A few specific errors appear repeatedly in real Rust code.
Forgetting to Decouple Independent References
The most frequent trap is using a single 'a when the struct contains references that have no reason to share a lifetime. The symptom is a borrow checker error where a variable “does not live long enough” even though you are only trying to use a different field. If your struct has multiple reference fields that come from separate scopes, add a distinct lifetime parameter for each one.
Misunderstanding What the Lifetime Annotation Means
Lifetime annotations describe a relationship, not a specific duration. Foo<'a> does not mean “the struct lives for exactly 'a.” It means “the struct cannot outlive the data borrowed with 'a.” The actual concrete lifetime is determined by the borrow checker at the call site and is always the minimum required to keep the references valid.
Thinking ‘a is the Struct's Lifetime:
A struct's lifetime parameter is not a property of the struct itself; it's a bound on how long the referenced data lives. The struct may be dropped earlier, but it can never be kept around after the borrowed data expires.
Storing a Reference Without an Owned Copy
If you find yourself adding lifetime parameters to a struct that should truly own its data, consider storing an owned type instead (e.g., String instead of &str). Lifetimes are for borrowing; they add complexity. Only use them when you genuinely need to share existing data without cloning.
Overly Complex Lifetime Webs
Nesting structs with multiple lifetime parameters and then passing them through functions can produce error messages that are hard to read. Start with a minimal number of lifetime parameters and add more only when the compiler proves you need them.
A Practical Example - Config Reader That Borrows File Content
A real-world pattern is a struct that borrows a byte slice representing the contents of a config file, then provides methods to extract values by reference. The struct never owns the file data; it references it.
struct Config<'a> {
raw: &'a str,
}
impl<'a> Config<'a> {
fn new(raw: &'a str) -> Self {
Config { raw }
}
fn get(&self, key: &str) -> Option<&'a str> {
self.raw
.lines()
.find(|line| line.starts_with(key))
.and_then(|line| line.split_once('='))
.map(|(_, value)| value.trim())
}
}
The key detail is the return type: Option<&'a str>. Because the returned string slice is borrowed from self.raw, which lives for 'a, the lifetime of the returned reference is tied to 'a. The caller can hold onto that slice only as long as the Config itself (and therefore the original borrowed raw) is alive.
Using it in main demonstrates the lifetime constraint:
fn main() {
let file_content = String::from("host=localhost\nport=8080\n");
let config = Config::new(&file_content);
let host = config.get("host").unwrap();
println!("host: {}", host);
// config and host are valid until file_content goes out of scope.
}
The Config struct never clones the file content; it only borrows it. The lifetime 'a guarantees that host (returned from get) cannot outlive file_content. If we tried to move file_content or drop it while host was still in use, the compiler would stop us.
Zero-Copy Parsing:
This design is common in high-performance parsers, network protocol decoders, and embedded systems where avoiding allocation is critical. The lifetime annotations make the borrowing explicit and safe.
Summary
Lifetime annotations on structs make the borrow relationships between a struct’s fields and the data they point to visible to the compiler. A single 'a ties all borrowed fields together, which is simple but can be too restrictive. Multiple lifetime parameters ('a, 'b, etc.) let you express that different references have independent expiration dates, giving the borrow checker more flexibility and avoiding false errors.
The most important takeaway: lifetimes in struct definitions describe constraints, not concrete durations. They ensure that a struct instance cannot outlive the data it borrows. When the compiler complains, the fix is usually either to add a missing lifetime parameter, split one lifetime into two, or reconsider whether the struct should own the data instead.