Parts 3 and 4 focused on lifetimes in function signatures. That is where you first encounter lifetime annotations, and the elision rules handle most of those cases automatically. But when you start putting references inside structs and enums, elision no longer saves you. Every reference field needs an explicit lifetime, and you need to understand what that lifetime means for the struct as a whole.
This post covers how to hold references safely in data structures, what the lifetime parameters on a struct actually constrain, and the patterns that make borrowed data structures practical in production Rust.
Why Structs With References Need Lifetime Annotations
When a struct holds a reference, the struct cannot outlive the data that reference points to. The compiler needs to track this constraint. Without a lifetime annotation, it has no way to do so.
// Does NOT compile
struct Config {
host: &str, // ERROR: missing lifetime specifier
port: u16,
}
// Correct: annotate the reference with a lifetime parameter
struct Config<'a> {
host: &'a str,
port: u16,
}The lifetime parameter 'a on the struct means: a Config<'a> instance cannot outlive the string data that host points to. The compiler tracks this and rejects any code that would violate it.
What the Lifetime Parameter Means
A lifetime parameter on a struct is a constraint, not a property of the struct itself. It says: “for this struct to be valid, the referenced data must be alive.” The struct is valid for as long as its lifetime parameter allows – which is bounded by the shortest-lived reference it holds.
flowchart TD
A[External data: String or other owned value] -->|reference held by| B["Struct<'a> { field: &'a T }"]
B --> C{Does struct outlive the external data?}
C -->|Yes| D[Compile Error: struct outlives borrowed content]
C -->|No| E[Valid: struct dropped before or with data]
E --> F[Struct can be freely used within 'a scope]
style D fill:#c0392b,color:#fff
style E fill:#27ae60,color:#fff
A Concrete Struct With Lifetime
#[derive(Debug)]
struct Excerpt<'a> {
text: &'a str,
source: &'a str,
}
fn main() {
let document = String::from("Rust is a systems programming language.");
let title = String::from("Introduction to Rust");
let excerpt = Excerpt {
text: &document,
source: &title,
};
println!("{:?}", excerpt);
// excerpt is valid here because document and title are both alive
}
// document and title dropped here - excerpt already gone (same scope)Both fields share the same lifetime 'a. This means the struct is valid only while both document and title are alive. The effective lifetime of the struct is the shorter of the two.
Multiple Lifetime Parameters in Structs
When a struct holds references to data with genuinely independent lifetimes, you can use multiple lifetime parameters:
#[derive(Debug)]
struct QueryContext<'query, 'db> {
query: &'query str, // lives as long as the query string
connection_string: &'db str, // lives as long as the db config
}
impl<'query, 'db> QueryContext<'query, 'db> {
fn new(query: &'query str, connection_string: &'db str) -> Self {
QueryContext { query, connection_string }
}
fn query(&self) -> &'query str {
self.query
}
fn connection(&self) -> &'db str {
self.connection_string
}
}
fn main() {
let db_config = String::from("postgres://localhost/mydb");
let result;
{
let user_query = String::from("SELECT * FROM users");
let ctx = QueryContext::new(&user_query, &db_config);
result = ctx.connection(); // &'db str - lives as long as db_config
println!("Query: {}", ctx.query());
} // user_query dropped, ctx dropped
// result is still valid because it came from db_config, not user_query
println!("Connection: {}", result);
}Using two separate lifetime parameters lets the compiler track each reference independently. The query() method returns a &'query str – valid only while the query string is alive. The connection() method returns a &'db str – valid for the longer lifetime of the db config. This precision is not possible with a single lifetime parameter.
Lifetime Annotations on impl Blocks
When implementing methods on a struct with lifetime parameters, you must declare those parameters on the impl block:
struct Parser<'a> {
input: &'a str,
cursor: usize,
}
// 'a is declared on impl and on the type name
impl<'a> Parser<'a> {
fn new(input: &'a str) -> Self {
Parser { input, cursor: 0 }
}
// Rule 3 applies: &self means output tied to self's lifetime
// Which is 'a through the struct
fn remaining(&self) -> &str {
&self.input[self.cursor..]
}
// Explicit: we want 'a on the return, not the anonymous self lifetime
fn full_input(&self) -> &'a str {
self.input
}
fn advance(&mut self, n: usize) {
self.cursor = (self.cursor + n).min(self.input.len());
}
}Notice the difference between remaining(&self) -> &str and full_input(&self) -> &'a str. The first uses elision – the return is tied to self‘s borrow lifetime (how long the Parser is borrowed). The second is explicitly tied to 'a – the lifetime of the underlying input data. These can differ: you might borrow the parser briefly, but the returned slice from full_input is valid for the full duration of the input data.
Lifetimes in Enums
Enums follow the same rules as structs. If any variant holds a reference, the enum needs a lifetime parameter:
#[derive(Debug)]
enum Token<'a> {
Word(&'a str),
Number(f64), // owned - no lifetime needed for this variant
Punctuation(char), // owned - no lifetime needed
EOF,
}
fn tokenize(input: &str) -> Vec> {
let mut tokens = Vec::new();
for word in input.split_whitespace() {
if let Ok(n) = word.parse::() {
tokens.push(Token::Number(n));
} else {
tokens.push(Token::Word(word)); // borrows from input
}
}
tokens.push(Token::EOF);
tokens
}
fn main() {
let input = String::from("hello 42 world 3.14");
let tokens = tokenize(&input);
for token in &tokens {
println!("{:?}", token);
}
} Even though only the Word variant holds a reference, the entire Token<'a> enum carries the lifetime parameter. The compiler needs this to track when the token list might contain borrowed data. The Number, Punctuation, and EOF variants are unaffected at runtime – they carry no references – but the type system needs the parameter to be consistent across all variants.
The Struct Lifetime Constraint in Practice
Here is a scenario that catches developers off guard:
struct View<'a> {
data: &'a [u8],
}
fn create_view() -> View<'???> {
let buffer = vec![1, 2, 3, 4, 5]; // owned by this function
View { data: &buffer }
// ERROR: cannot return value referencing local variable `buffer`
// buffer is dropped when function returns
}You cannot return a struct that borrows from a local variable. The struct’s lifetime is bounded by the data it references, and local variables are dropped when the function returns. The fix is to either accept the data as a parameter or return owned data:
// Option 1: accept the data, borrow it
fn create_view(buffer: &[u8]) -> View<'_> {
View { data: buffer }
}
// Option 2: return owned data instead of borrowing
struct OwnedView {
data: Vec,
}
fn create_owned_view() -> OwnedView {
let buffer = vec![1, 2, 3, 4, 5];
OwnedView { data: buffer } // move the buffer into the struct
} Borrowed vs Owned: The Design Decision
When you design a struct, you have a choice: hold references (borrow) or hold owned data. This is not just a memory question – it affects how the struct can be used.
// Borrowed version: zero allocation, but tied to source data's lifetime
struct ConfigRef<'a> {
host: &'a str,
port: u16,
}
// Owned version: allocates, but fully independent
struct Config {
host: String,
port: u16,
}
// The Cow pattern: borrow when possible, own when necessary
use std::borrow::Cow;
struct FlexConfig<'a> {
host: Cow<'a, str>, // either borrowed &'a str or owned String
port: u16,
}
impl<'a> FlexConfig<'a> {
fn from_ref(host: &'a str, port: u16) -> Self {
FlexConfig { host: Cow::Borrowed(host), port }
}
fn from_owned(host: String, port: u16) -> Self {
FlexConfig { host: Cow::Owned(host), port }
}
fn into_owned(self) -> FlexConfig<'static> {
FlexConfig {
host: Cow::Owned(self.host.into_owned()),
port: self.port,
}
}
}Cow<'a, str> (Clone on Write) is a standard library type that can hold either a borrowed reference or an owned value. It is the idiomatic solution when you want to avoid allocating in the common case (passing in a string literal or existing String) but need the option to own the data when the source is going away. The into_owned() method converts the borrowed form to owned, giving you a 'static lifetime.
Lifetime Bounds on Generic Structs
When a generic struct holds a reference to a generic type, both the lifetime and the type parameter interact:
use std::fmt::Display;
// T must implement Display, and the reference must live for 'a
struct DisplayWrapper<'a, T: Display> {
value: &'a T,
label: &'a str,
}
impl<'a, T: Display> DisplayWrapper<'a, T> {
fn new(value: &'a T, label: &'a str) -> Self {
DisplayWrapper { value, label }
}
fn show(&self) {
println!("{}: {}", self.label, self.value);
}
}
fn main() {
let number = 42;
let wrapper = DisplayWrapper::new(&number, "answer");
wrapper.show(); // "answer: 42"
}The bound T: Display is a trait bound – it constrains what types T can be. The 'a is a lifetime bound – it constrains how long the reference must be valid. Both appear in the angle brackets and work independently.
Self-Referential Structs: What Is Not Possible (Yet)
One limitation that surprises developers is that you cannot safely build a struct that references its own fields using regular lifetime annotations:
// This cannot be expressed with standard lifetimes
struct SelfRef {
data: String,
// pointer: &'??? str, // what lifetime goes here?
// The reference would point INTO data, but data moves with the struct
}
// When you move SelfRef, data moves to a new memory address
// Any pointer into it would become danglingSelf-referential structs are possible using Pin and unsafe code, which is exactly how async state machines work internally. We cover that in Part 8 on Pin and Unpin. For now, the practical advice is: if you find yourself wanting a struct field that points into another field of the same struct, consider using indices or offsets instead of references, or restructuring the data so the referenced part is separately owned.
Common Patterns Summary
// Pattern 1: Simple borrowed struct
struct View<'a> {
slice: &'a [u8],
}
// Pattern 2: Multiple independent lifetimes
struct Join<'a, 'b> {
left: &'a str,
right: &'b str,
}
// Pattern 3: Mix of owned and borrowed
struct Record<'a> {
id: u64, // owned
name: &'a str, // borrowed
tags: Vec, // owned collection
}
// Pattern 4: Cow for flexibility
use std::borrow::Cow;
struct Message<'a> {
body: Cow<'a, str>,
}
// Pattern 5: Generic with lifetime bound
struct Cache<'a, T> {
key: &'a str,
value: T,
} What Comes Next
Lifetimes in structs establish the foundation for the more advanced uses that follow. In Part 6, we apply this to trait definitions and implementations – where lifetimes interact with dynamic dispatch, trait objects, and generic bounds in ways that require careful annotation to get right.
References
- The Rust Programming Language – “Lifetime Annotations in Struct Definitions” (https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html#lifetime-annotations-in-struct-definitions)
- Rust Standard Library – “Cow: Clone on Write” (https://doc.rust-lang.org/std/borrow/enum.Cow.html)
- The Rustonomicon – “Lifetimes” (https://doc.rust-lang.org/nomicon/lifetimes.html)
- Rust by Example – “Structs with Lifetimes” (https://doc.rust-lang.org/rust-by-example/scope/lifetime/struct.html)
- JetBrains RustRover Blog – “The State of Rust Ecosystem 2025” (https://blog.jetbrains.com/rust/2026/02/11/state-of-rust-2025/)
