Go to Rust Series: ← No Garbage Collector | Series Overview | Mut vs Immutable →


The Concept Go Doesn’t Need

Lifetimes are Rust’s way of tracking how long references are valid-all at compile time.

Go doesn’t need lifetimes because the garbage collector tracks reference validity at runtime. Rust needs them because there’s no GC.

Go: References Just Work

Go:

func getFirst(a, b string) string {
    if len(a) > len(b) {
        return a
    }
    return b
}

func main() {
    x := "short"
    y := "longer"
    result := getFirst(x, y)
    fmt.Println(result)
}

No annotations needed. GC ensures result stays valid.

Rust: Compiler Needs Proof

Rust (won’t compile without lifetimes):

fn get_first(a: &str, b: &str) -> &str {
    if a.len() > b.len() {
        a
    } else {
        b
    }
}

Error:

error[E0106]: missing lifetime specifier
 --> src/main.rs:1:37
  |
1 | fn get_first(a: &str, b: &str) -> &str {
  |                 ----     ----     ^ expected named lifetime parameter

Rust needs to know: does the returned reference come from a or b? How long is it valid?

Fixed with lifetime annotation:

fn get_first<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() > b.len() {
        a
    } else {
        b
    }
}

fn main() {
    let x = "short";
    let y = "longer";
    let result = get_first(x, y);
    println!("{}", result);
}

The 'a annotation says: “The returned reference lives as long as the shorter of a or b.”

What Are Lifetimes?

Lifetimes are compile-time annotations that tell the compiler how long references are valid.

Syntax:

  • 'a - A lifetime parameter (pronounced “tick a”)
  • &'a str - A reference with lifetime 'a

Example:

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

This says:

  • x and y both have lifetime 'a
  • The return value has lifetime 'a
  • Therefore: return value lives as long as both inputs

Lifetime Elision: When You Don’t Need Annotations

Rust has rules to infer lifetimes in simple cases:

Rule 1: Each parameter gets its own lifetime

Explicit:

fn print_str<'a>(s: &'a str) {
    println!("{}", s);
}

Elided (compiler infers):

fn print_str(s: &str) {
    println!("{}", s);
}

Rule 2: If one input lifetime, output gets same lifetime

Explicit:

fn first_word<'a>(s: &'a str) -> &'a str {
    s.split_whitespace().next().unwrap_or("")
}

Elided:

fn first_word(s: &str) -> &str {
    s.split_whitespace().next().unwrap_or("")
}

Rule 3: Multiple inputs, but one is &self, output gets &self lifetime

Explicit:

impl MyStruct {
    fn get_data<'a>(&'a self) -> &'a str {
        &self.data
    }
}

Elided:

impl MyStruct {
    fn get_data(&self) -> &str {
        &self.data
    }
}

When elision doesn’t work, you need explicit annotations.

Dangling References: Go vs Rust

Go: GC Saves You

Go:

func dangling() *int {
    x := 42
    return &x  // OK! GC extends x's lifetime
}

func main() {
    ptr := dangling()
    fmt.Println(*ptr)  // 42
}

GC realizes x is referenced and keeps it alive.

Rust: Compiler Rejects

Rust:

fn dangling() -> &i32 {
    let x = 42;
    &x  // ERROR! x doesn't live long enough
}

Error:

error[E0106]: missing lifetime specifier
error[E0515]: cannot return reference to local variable `x`

Rust prevents the bug at compile time.

Fix: Return owned value

fn not_dangling() -> i32 {
    42  // Return value, not reference
}

Structs with References

Go: No Annotations

Go:

type Excerpt struct {
    text *string
}

func main() {
    s := "Hello, world!"
    e := Excerpt{text: &s}
    fmt.Println(*e.text)
}

Works. GC tracks the reference.

Rust: Lifetime Annotations Required

Rust:

struct Excerpt<'a> {
    text: &'a str,
}

fn main() {
    let s = String::from("Hello, world!");
    let e = Excerpt { text: &s };
    println!("{}", e.text);
}

The 'a in Excerpt<'a> says: “This struct holds a reference that lives for 'a.”

What happens if the reference outlives the data?

fn main() {
    let e;
    {
        let s = String::from("Hello");
        e = Excerpt { text: &s };
    }  // s is dropped here

    println!("{}", e.text);  // ERROR!
}

Error:

error[E0597]: `s` does not live long enough

Rust prevents use-after-free at compile time.

Multiple Lifetimes

Sometimes you need different lifetimes for different parameters:

Rust:

fn announce<'a, 'b>(
    announcement: &'a str,
    context: &'b str
) -> &'a str {
    println!("Context: {}", context);
    announcement  // Return announcement (lifetime 'a)
}

fn main() {
    let ann = String::from("Important!");
    {
        let ctx = String::from("Some context");
        let result = announce(&ann, &ctx);
        println!("{}", result);
    }
    // ctx is dropped, but result (from ann) is still valid
}

This says:

  • announcement has lifetime 'a
  • context has lifetime 'b
  • Return value has lifetime 'a (tied to announcement, not context)

Static Lifetime: Lives Forever

Rust:

fn get_message() -> &'static str {
    "This string literal lives for the entire program"
}

fn main() {
    let msg = get_message();
    println!("{}", msg);
}

'static means the reference is valid for the entire program duration (e.g., string literals, static variables).

Example with static variable:

static GLOBAL_MESSAGE: &str = "Global";

fn get_global() -> &'static str {
    GLOBAL_MESSAGE
}

Real-World Example: Iterator

Go: No Lifetime Annotations

Go:

type Words struct {
    text string
    pos  int
}

func (w *Words) Next() *string {
    // Find next word and return reference
    // GC handles lifetime
}

Rust: Lifetime Tied to Struct

Rust:

struct Words<'a> {
    text: &'a str,
    pos: usize,
}

impl<'a> Words<'a> {
    fn new(text: &'a str) -> Self {
        Words { text, pos: 0 }
    }

    fn next(&mut self) -> Option<&'a str> {
        // Find next word
        // Returned reference has same lifetime as original text
        if self.pos >= self.text.len() {
            return None;
        }

        let start = self.pos;
        while self.pos < self.text.len() && !self.text.as_bytes()[self.pos].is_ascii_whitespace() {
            self.pos += 1;
        }

        let word = &self.text[start..self.pos];
        self.pos += 1;  // Skip whitespace

        Some(word)
    }
}

fn main() {
    let text = String::from("Hello Rust world");
    let mut words = Words::new(&text);

    while let Some(word) = words.next() {
        println!("{}", word);
    }
}

The 'a ensures returned words live as long as the original text.

Common Lifetime Patterns

Pattern 1: Returning One of Many References

Rust:

fn choose<'a>(first: &'a str, second: &'a str, condition: bool) -> &'a str {
    if condition { first } else { second }
}

Return value tied to shortest lifetime of inputs.

Pattern 2: Struct Holding Multiple References

Rust:

struct Context<'a, 'b> {
    name: &'a str,
    description: &'b str,
}

impl<'a, 'b> Context<'a, 'b> {
    fn print(&self) {
        println!("{}: {}", self.name, self.description);
    }
}

Different fields can have different lifetimes.

Pattern 3: Lifetime Bounds

Rust:

fn longest_with_announcement<'a, T>(
    x: &'a str,
    y: &'a str,
    ann: T,
) -> &'a str
where
    T: std::fmt::Display,
{
    println!("Announcement: {}", ann);
    if x.len() > y.len() { x } else { y }
}

Combining lifetimes with generics.

When Lifetimes Get Complex

Rust:

struct Parser<'a> {
    input: &'a str,
}

impl<'a> Parser<'a> {
    fn parse(&self) -> Result<Token<'a>, Error> {
        // Token references data from input
        // Token's lifetime tied to Parser's lifetime
        unimplemented!()
    }
}

struct Token<'a> {
    value: &'a str,
}

This gets complex fast! But it ensures memory safety without GC.

The Mental Model

Go:

  • “Pass references around”
  • “GC will handle it”

Rust:

  • “How long does this reference live?”
  • “Does it outlive what it points to?”
  • “Can the compiler prove safety?”

Why Go Developers Struggle

Coming from Go, lifetimes feel like unnecessary ceremony:

Go version (simple):

func process(data string) string {
    return data
}

Rust version (explicit):

fn process<'a>(data: &'a str) -> &'a str {
    data
}

But Rust’s explicitness prevents bugs:

Go (compiles, crashes at runtime):

func getRef() *string {
    s := "temporary"
    return &s  // Looks safe, GC saves it
}

Rust (won’t compile):

fn get_ref() -> &str {
    let s = "temporary".to_string();
    &s  // ERROR! s doesn't live long enough
}

Conclusion

Lifetimes in Rust:

  • Ensure references never outlive their data
  • Prevent dangling pointers at compile time
  • Add complexity but eliminate runtime bugs

Go’s approach:

  • No lifetime annotations needed
  • GC handles everything at runtime
  • Simpler to write, potential runtime issues

Trade-off:

  • Go: Faster to write, runtime safety net
  • Rust: More upfront work, compile-time guarantees

Next: Understanding mut vs immutable-why Rust makes you choose explicitly.


Go to Rust Series: ← No Garbage Collector | Series Overview | Mut vs Immutable →


Lifetimes verdict: Hardest concept for Go developers. But they prevent entire classes of bugs that GC can’t catch.