Rust Lifetimes: Advanced Bounds, HRTB and Elision Rules
Rust lifetime errors mean a borrow outlives its owner.
20+ years shipping production backend systems. Everything here is grounded in real deployments.
- ✓Comfortable writing Rust functions, structs, and traits with cargo
- ✓Hands-on experience with borrowing, references, and the borrow checker
- ✓Basic familiarity with closures, threads, and generic type parameters
- Elision assigns output lifetimes from inputs by rule: one input lifetime flows to outputs, &self receivers dominate, and every other case must be written out by hand
- Multiple input references with elided outputs never compile: you must name the lifetimes and decide whether the return borrows from one side or unifies both under a single 'a
- 'static on a reference means the data lives for the whole program, but T: 'static only means the type owns its data and can be held indefinitely, not that any value lives forever
- Structs that hold references must declare lifetime parameters, and variance decides whether a short-lived value can coerce to a longer-lived slot: shared references are covariant, mutable and interior-mutable types are invariant
- Higher-ranked bounds written for<'a> let a function accept any closure or parser that works for every lifetime instead of pinning down one concrete lifetime at the call site
- E0106 means you omitted a required lifetime, E0597 means a value is dropped while still borrowed, and E0502/E0506 mean you tried to mutate through an alias that is already borrowed as shared
Think of Rust references as library books with due-date stamps. Every borrowed book has a slip saying when it must be returned, and the librarian refuses any plan where you promise to lend a book to a friend after your own due date has passed. Lifetime annotations are those due-date slips written into the code: most of the time the librarian fills them in for you from three simple rules, but when your plan involves two books from different shelves, a book you want to keep forever, or a lending scheme that must work for any due date at all, you have to stamp the slips yourself so the promise can be checked before anyone walks out the door.
You've written enough Rust that the borrow checker no longer scares you, but it still surprises you. A method that compiled yesterday fails when you add a second parameter. A helper that works on concrete types falls apart the moment you generalize it. The error points at a lifetime you never wrote, and you fix it by sprinkling 'a until the compiler stops complaining.
That's not a sustainable way to work. Lifetimes aren't line noise the compiler demands for fun. They're the machine-checked record of a simple promise: no reference outlives the data it points at. Once you can read that promise, most errors stop looking cryptic.
Elision is where the confusion starts. Three small rules let you omit lifetimes in function signatures, and they cover an enormous amount of everyday code. But they don't cover everything, and the failure mode is always the same: code that looks like it should compile doesn't, because you've hit a case the rules deliberately refuse to guess at.
This guide works through those rules one at a time, then builds upward: what 'static really means on references versus type bounds, how structs carry lifetimes and why variance matters, how trait bounds and higher-ranked bounds express borrowing contracts for generic code, and finally a full error-pattern appendix where each classic failure is shown broken, explained, and fixed.
You'll leave able to predict elision instead of guessing, to choose between unifying lifetimes and keeping them independent, and to read E0106, E0495, E0597, E0502, and E0506 as precise instructions rather than insults.
Elision Rule One: A Single Input Lifetime Flows to the Output
The first elision rule is the one you'll meet most often: when a function takes exactly one reference in input position and returns a reference with an elided lifetime, the compiler assigns the input's lifetime to the output. Writing fn first(s: &str) -> &str is shorthand for fn first<'a>(s: &'a str) -> &'a str. The promise is direct and checkable: whatever you return must be borrowed from that single input, and the caller knows the result lives exactly as long as the argument you passed.
Input position deserves a precise definition because the rules key off it. A lifetime is in input position when it appears in a function parameter type: &str, &mut Vec<u8>, Option<&Config>. Lifetimes inside the return type are in output position. Lifetimes inside trait bounds or where clauses are neither, and elision never invents them. When you read fn first(s: &str) -> &str, the &str parameter carries one elided input lifetime, the return carries one elided output lifetime, and rule one connects them.
The worked case is a function returning the first line of a document. The implementation scans for a newline and slices the input, and every borrow in the body traces back to s. Because there is exactly one input lifetime, the compiler doesn't need your help: the elided output is that lifetime, and returning a slice of a local String would fail with a dangling-reference error rather than an elision complaint. Rule one covers methods without receivers too, so a free function over one borrowed config struct behaves identically.
Where rule one stops is just as instructive. If the function takes no references at all but returns one, such as a constructor that wants to hand out &'static str, elision cannot help and you must write the 'static explicitly. If the function takes two references, rule one doesn't apply either, even if one of them is obviously the source. The rules refuse to guess between candidates, and that refusal is a feature: it forces the borrowing contract into the signature where every caller can see it.
Build the habit of expanding elision mentally at every signature you write. Ask which input lifetime each output could come from, and check whether the answer is unique. When it is, elision is doing exactly what you'd write by hand. When it isn't, the compiler will demand names, and the next sections show how to choose them well.
Option returns follow the same rule without extra syntax. A function like fn first_ok(doc: &str) -> Option<&str> still carries one input lifetime, so the Option-wrapped output borrows from the input. Combinators preserve the region: calling .map(str::trim) on that Option keeps the borrow tied to the original argument, and the caller holds the document alive for as long as any derived value is used. Wrapping the output in Option, Result, or an iterator adapter never creates a new lifetime; it only transports the existing one.
The mutable variant behaves identically with reborrowing in the background. A signature like fn first_mut(buf: &mut str) -> &mut str assigns the input's lifetime to the output, and each call reborrows rather than moves the original borrow. That reborrowing is why successive calls in a loop compile: every iteration's mutable borrow ends before the next begins, even though all of them derive from one variable. Holding two such outputs simultaneously still fails, exactly as aliasing rules demand.
Reading the diagnostic completes the skill. When rule one should have applied but the body returns a local, rustc reports that a borrowed value does not live long enough and points at the local's drop. When the signature has zero inputs and an elided output, the error instead demands an explicit lifetime and suggests 'static for constants. Both messages assume you can already expand the elision; with that habit, each points at the single inconsistent assumption rather than reading as noise.
Function pointer types deserve a final note because their elision differs from function items. The type fn(&str) -> &str written in an argument position means for<'a> fn(&'a str) -> &'a str: a higher-ranked signature where the caller picks a fresh lifetime per invocation. That is why parser combinators built on fn pointers accept tokens of any lifetime without annotation. Contrast this with a generic F: Fn(&'x str) bound pinned to one region, which rejects mixed-lifetime inputs. When a closure-based API fights you with region errors, testing the same logic through a fn pointer first isolates whether the bound shape or the closure's captures are at fault, and the answer determines whether you need for<'a> or merely better capture discipline.
Elision Rules Two and Three: Why &self Methods Just Work
Methods look magical because they almost never need lifetime annotations, and rules two and three are the reason. Rule two says: when a method takes &self or &mut self and returns an elided reference, the output lifetime is the receiver's lifetime. A getter like fn name(&self) -> &str means fn name<'a>(&'a self) -> &'a str. The returned string lives as long as the borrow of the struct, which matches every caller's intuition about getters.
Rule three generalizes this: if there are multiple input lifetimes but exactly one of them is the receiver (&self or &mut self), the receiver's lifetime still wins for all elided outputs. Consider fn get(&self, key: &str) -> &str on a lookup table. Two inputs exist, so rule one can't fire, but the receiver rule assigns the output to &self. The signature promises the result borrows from the table, not from the short-lived key, which is precisely the contract a lookup should offer.
The worked contrast is a method that deliberately returns data borrowed from a non-receiver argument. A parser method like fn parse<'m, 'i>(&'m self, input: &'i str) -> Token<'i> must name both lifetimes because the token borrows from the input, not from the parser. Elision would have assigned the receiver's lifetime, promising tokens that live as long as the parser even when the input buffer is dropped first. Writing both lifetimes documents the real dependency and lets the compiler reject misuse.
Mutable receivers follow the same pattern with reborrowing doing quiet work behind the scenes. A method fn next_token(&mut self) -> Option<&str> ties each returned token to the mutable borrow of the lexer, which is why holding a token across a second &mut self call fails. That failure is correct: the lexer mutates its buffer position, and a pre-existing slice could observe torn state. The error message about conflicting borrows is rule two protecting you.
Treat receiver elision as a default you're allowed to override. When the output genuinely derives from the receiver, elision states it cleanly. When it derives from anywhere else, spell both lifetimes and let the signature tell the truth. Reviewers should be able to answer where does this borrow come from from the signature alone, without opening the method body.
Iterator methods are rule two under load. A lexer exposing fn next_token(&mut self) -> Option<&str> ties every token to the mutable borrow of the lexer, so holding a token while advancing the lexer fails with a conflicting-borrow error. The failure protects against observing buffer state mid-advance. Designs that need lookahead either copy the small token, restructure into indexed access with split borrows, or switch the lexer to emit owned tokens when the performance budget allows.
Builder patterns lean on the same rule in a friendlier direction. Methods like fn with_name(&mut self, n: &str) -> &mut Self return the receiver's own borrow, enabling call chains where each link reborrows the last. Because the output lifetime is the receiver's, the whole chain stays valid exactly as long as the original mutable borrow. Returning anything else from a builder method would break chaining in ways the signature makes visible.
Trait declarations inherit elision, and implementations must honor it. Declaring fn describe(&self) -> &str in a trait promises receiver-borrowed output for every implementor; an impl that tries to return data borrowed from a global registry table under a shorter lifetime fails at the impl boundary. The fix is either storing the data in the struct so the receiver genuinely owns the borrow, or changing the trait to name the real source lifetime. Elision in traits is a contract all implementors sign.
Mutable receiver chains power iterator adapters and builders alike. A method like fn iter_mut(&mut self) -> IterMut<'_, T> ties the iterator's lifetime to the mutable borrow of the collection, which is why holding the iterator blocks all other access until it drops. Chained adapters preserve the region transitively: map and filter wrap the same borrow without shortening or extending it. The practical consequence is that iterator invalidation is a compile error rather than a runtime hazard. When a design needs two simultaneous views, the answer is splitting borrows at the field level or restructuring into indexed passes, because the receiver rule will not grant overlapping mutable access no matter how the methods are named.
Multiple Input Lifetimes: The Case Elision Refuses to Guess
When a function takes two or more reference parameters and returns a reference, elision stops and demands names. The function fn longest(x: &str, y: &str) -> &str cannot compile, because the compiler has no basis for choosing whether the result borrows from x, from y, or from whichever lives shorter. Any guess would silently commit callers to a borrowing contract they never agreed to, so the rules require you to state it.
Unifying both inputs under one lifetime is the common answer: fn longest<'a>(x: &'a str, y: &'a str) -> &'a str. This signature says the result lives as long as the shorter of the two inputs, and the body may return either side. Callers pay a small price: passing a long-lived static string alongside a short-lived local constrains the result to the short one. That's sound, because the result might be the short-lived argument, and the type system must assume the worst.
Independent lifetimes express a different contract. The signature fn first<'a, 'b>(x: &'a str, _y: &'b str) -> &'a str promises the result always comes from x, leaving y unconstrained. Callers keep full flexibility on the unused side. Choosing between unification and independence is a design decision about the function's promise: does it select among its inputs, or does it transform one specific input while merely inspecting the others?
The subtle trap is over-unification in larger signatures. A function taking a long-lived configuration reference and a short-lived request buffer under a single 'a forces the configuration borrow to end with the request, which can serialize otherwise independent work or extend a lock hold. Naming them 'cfg and 'req keeps the borrows separate, lets the config borrow outlive the call freely, and documents which data flows into the return value through the output's lifetime alone.
Read every multi-input signature as a question the author answered: which inputs can reach the output? If the output carries 'a, every 'a input is a candidate source, and the result is bounded by the shortest. If some input carries its own lifetime absent from the output, that input is inspect-only. This reading skill turns E0106 from a nuisance into a design prompt.
Lifetime subtyping quietly helps callers of multi-input functions. Passing a &'static str where a short &'x str is expected always works, because longer regions coerce to shorter ones at no cost. A unified signature fn longest<'a>(x: &'a str, y: &'a str) -> &'a str therefore accepts any mix of static and local arguments, constraining only the result. The coercion flows inward at the call site, never outward from the function, which keeps the contract honest.
Where clauses express relationships unification cannot. The bound fn pick<'a, 'b>(x: &'a str, y: &'b str) -> &'a str where 'b: 'a states that 'b outlives 'a without forcing the two to be equal, useful when the function reads y while returning views of x and the caller must guarantee y lives at least as long. Most application code never needs this form, but library signatures for streaming adapters and scoped callbacks use it to accept strictly more programs than unification allows.
The characteristic diagnostic is E0621, explicit lifetime required, which appears when an elided output has several candidate inputs. The note lists the candidate lifetimes and asks which one the output should use. Answer by tracing the body's return expressions: whichever parameter each return path borrows determines the annotation. When different paths borrow different parameters, unification under one 'a is the sound answer, and the result's shorter-lived bound is the price of that flexibility.
Higher-order functions force the same input-output mapping at one remove. A signature like fn apply<'a>(f: fn(&'a str) -> &'a str, s: &'a str) -> &'a str unifies the callback, its argument, and the result under one region, which suits pipelines where every stage borrows from the same buffer. When stages borrow from different buffers, the signature must split: fn pipe<'a, 'b>(f: fn(&'a str) -> String, s: &'b str) -> String owns the boundary crossing instead. Callback-heavy APIs that skip this analysis end up demanding 'static closures, which silently excludes every borrowing callback. Naming the regions the callbacks actually need keeps combinator libraries open to zero-copy users.
'static Demystified: References, Owned Types, and T: 'static
No lifetime causes more confusion than 'static, because it means three related but distinct things depending on where it appears. On a reference type like &'static str, it means the data itself lives for the entire program: string literals, leaked allocations, and global constants qualify, while a local String never does. The compiler proves this the same way it proves any borrow, with the program's full duration as the region.
As a trait bound T: 'static, the meaning shifts. It does not require any value to live forever. It requires the type to own all its data, with no borrowed references of shorter lifetime inside. String satisfies String: 'static because it owns its buffer; &'a str does not satisfy &'a str: 'static for any short 'a because it borrows. An owned struct containing only owned fields meets the bound even if every instance is dropped within milliseconds.
The bound that trips production code is thread::spawn, which requires F: Send + 'static on the closure. This exists because the spawned thread may outlive the spawning scope, so nothing it carries may borrow from that scope. Moving owned values into the closure satisfies the bound; borrowing a local does not. The standard fix is cloning an Arc before the move, giving the thread shared ownership instead of a borrow that could dangle.
A related confusion is manufacturing 'static with Box::leak or OnceLock. Leaking converts an owned allocation into a &'static reference by promising never to free it, which suits global configuration loaded once but bleeds memory if used per request. Prefer owned values flowing through explicit lifetimes, and reserve leaking for genuinely permanent singletons where the one-time cost is measured and documented.
When you see 'static, ask which of the three you need: a reference valid forever, a type that owns its data, or a permanent global. Most thread and channel APIs want the second. Most zero-copy parsers want explicit short lifetimes instead. Reaching for 'static to silence an error usually means the ownership design needs one more step, not a bigger hammer.
Scoped threads changed the economics of 'static. The API std::thread::scope lets spawned threads borrow stack data because the scope joins every thread before returning, proving no borrow escapes. Code that once cloned Arcs purely to satisfy spawn can now pass plain references into scoped threads with zero allocation. Reserve 'static-demanding spawn for detached threads whose lifetimes genuinely exceed the spawning function, and prefer scopes everywhere else.
Static promotion covers another common case invisibly. Expressions like const MSG: &str = "hello" or a bare "hello" literal in a function body already have 'static type without any annotation, because the compiler hoists constant data into the binary. Functions returning such constants write -> &'static str explicitly, but callers coerce the result down to any shorter region for free. Promotion is why literal defaults compose with borrowed APIs effortlessly.
Globals need initialization discipline rather than leaks. A static OnceLock<Config> or the LazyLock wrapper runs a one-time initializer on first access and hands out &'static Config thereafter, with no leak-per-request hazard. The pattern suits configuration, compiled regex sets, and interner tables: values computed once, shared forever, freed never, with the single permanent cost stated in one place. Contrast this with per-item Box::leak, which hides an unbounded cost behind identical syntax.
E0310 is the 'static diagnostic in its Sunday clothes: parameter type may not live long enough, usually at a thread::spawn or a type-erased boundary. The note suggests T: 'static because the spawned thread can outlive the spawning scope, and any borrow from that scope would dangle. The idiomatic repair moves owned data or clones an Arc into the closure, converting the borrow into shared ownership before the boundary. Scoped threads offer the alternative when joining is acceptable: borrows flow in directly with no bound at all. Read E0310 as a question about who outlives whom, and choose the repair that matches the thread's actual lifetime rather than the quickest annotation that compiles.
Struct Lifetime Parameters and a Working Variance Primer
A struct that holds a reference must declare the fact: struct View<'a> { text: &'a str }. The parameter 'a is part of the type, so View<'a> and View<'b> are different types when the lifetimes differ. Methods on the struct redeclare or reuse the parameter, and constructors naturally tie the struct's lifetime to the borrowed data. This is the mechanism behind every zero-copy design in Rust, from parsers to request contexts.
Variance decides how those parameterized types relate when lifetimes differ. Shared references are covariant: &'long str coerces to &'short str wherever a shorter borrow suffices, because read-only access through a longer-lived borrow is always safe to shorten. This is why passing a 'static string into a function expecting a short borrow just works. The compiler quietly shrinks the lifetime, and no caller notices.
Mutable and interior-mutable types are invariant: Cell<&'a T>, Mutex<&'a T>, and &mut &'a T never coerce across lifetimes. The reason is soundness under aliasing. If a Cell holding a long borrow could be treated as holding a short one, code could store a short-lived reference through the alias and later read it through the original long-lived view after the data died. Invariance blocks that laundering path at the cost of occasionally rejecting programs that look harmless.
The practical fallout shows up in struct design. A holder using &'a T flows smoothly through generic code, while the same holder using Cell<&'a T> or MutexGuard with borrowed data demands exact lifetime matches and produces errors about invariance that baffle newcomers. When you need interior mutability plus borrowed content, expect to restructure: store owned data, scope the guard narrowly, or split the struct so the mutable cell never wraps the borrow.
PhantomData exists for the boundary case where a type logically owns a lifetime without storing a reference, such as a handle into an arena. Adding PhantomData<&'a T> tells the compiler and the variance system about the relationship so drop-checking and coercions stay correct. Reach for it when the struct is lifetime-parameterized but the field list doesn't mention the reference directly.
PhantomData carries lifetime intent without storage. A handle struct like struct Cursor<'a> { pos: usize, _tag: PhantomData<&'a ()> } claims a borrow relationship the fields don't show, which drives drop checking and variance correctly. Without the marker, the compiler treats the struct as owning no borrow and may allow uses that outlive the arena the position indexes into. The marker costs zero bytes and converts a logical relationship into a checked one.
Drop check adds a quiet constraint worth knowing. A struct with a Drop implementation cannot freely shorten its lifetime parameters in all positions, because the destructor might observe borrowed data during teardown. Generic structs combining Drop with borrowed fields occasionally demand surprising bounds. The practical response is keeping destructors away from borrowed fields: run teardown logic on owned state, and let borrows end before destruction begins.
Method-level lifetime rebinding rounds out the picture. An impl block can introduce fresh parameters its struct lacks, as in fn translate<'s>(&self, dict: &'s Dict) -> impl Iterator<Item = &'s str>, tying the returned iterator to the dictionary rather than to self. This keeps borrow scopes minimal: iterating the translation borrows only the dictionary, leaving the struct free for concurrent use. Fresh method lifetimes are the tool for outputs that bypass the struct's own borrows entirely.
Variance also governs function signatures through subtyping. A function expecting fn(&'short str) accepts fn(&'long str) because parameter types are contravariant: the callee promising to handle long borrows can serve callers offering short ones. Return types stay covariant, matching the reference behavior. These rules compose so that refactoring a helper to accept shorter borrows never breaks callers passing longer ones. The same principle explains why &'static str arguments flow into every borrowed API without friction. Designing parameters around the shortest borrow you truly need maximizes every caller's freedom, and variance carries that freedom through layers of indirection automatically.
Trait Bounds With Lifetimes: T: 'a and Objects That Borrow
Generic code that stores or returns borrowed data needs outlives bounds, written T: 'a. The bound says every reference inside T must live at least as long as 'a. A cache declared struct Cache<'a, T: 'a> can hold values borrowing from 'a, and the compiler rejects any insertion of shorter-lived data. Without the bound, the struct would promise storage it cannot guarantee, so the language requires the constraint wherever a generic parameter flows into a lifetime-parameterized field.
Where clauses carry the same idea for function bodies. A function fn store<'a, T: 'a>(slot: &mut Option<T>, v: T) compiles only with the bound, because the slot's content must survive the assignment. Callers with owned types like String satisfy T: 'a for any 'a automatically, since owned types outlive every region. Callers with borrowed types must prove their borrows last, which is exactly the check you want at a storage boundary.
Trait objects add a second lifetime: the object's own bound. Writing Box<dyn Display + 'a> says the erased concrete type may borrow, but only within 'a. The default when you write Box<dyn Display> is 'static, meaning fully owned, which surprises developers whose concrete type borrows from local data. Naming the object lifetime explicitly, or restructuring to borrow the trait object as &'a dyn Display, resolves the mismatch by stating how long the erased value lives.
Impl blocks need matching declarations. An implementation impl<'a> Processor for View<'a> ties the trait behavior to the struct's borrow, and methods can then return data under 'a without further annotation. Forgetting the impl-level parameter produces errors about hidden lifetimes that read as noise until you recognize the pattern: the trait promises borrowing behavior the bare impl cannot express.
Associated types and GATs extend the story for advanced abstractions like lending iterators. Most application code never needs them, but the underlying principle is unchanged: every borrow crossing an abstraction boundary needs a named region both sides agree on. T: 'a is that agreement for generics, and the object lifetime is that agreement for dynamic dispatch.
Argument-position impl Trait hides a lifetime that often needs naming. Writing fn log(msg: &impl Display) desugars to a generic with an anonymous lifetime, which suffices until the function must store or return derived borrows. The repair spells the generic out: fn log<'a, T: Display + 'a>(msg: &'a T). Owned message types satisfy the bound silently, while borrowed ones are checked at the call boundary where the data is visible.
Multiple trait bounds share one object lifetime. A parameter typed &dyn (Display + Debug) or Box<dyn Handler + 'reg> carries all behavior bounds plus a single region bound, keeping signatures compact. When two different regions are genuinely needed, generics with separate parameters replace the object: fn merge<'a, 'b, A: Display + 'a, B: Debug + 'b>. Dynamic dispatch buys openness at the cost of one region; heterogeneous regions mean static generics.
E0310 names the bound failure directly: parameter type may not live long enough. It appears when a generic flows into storage or a thread without its outlives bound, and the note suggests adding T: 'a or T: 'static. Apply the suggestion at the struct or function that stores the value, not at distant callers. The bound belongs where the promise is made, which keeps error sites adjacent to the design decision they guard.
Threaded trait objects stack three bounds where beginners expect one. A registry shared across workers declares Box<dyn Handler + Send + Sync + 'reg> or its Arc equivalent, combining behavior, thread-safety, and region in a single object type. Each bound answers a different reviewer question: what it does, where it may run, how long it lives. Omitting Send compiles on one thread and fails at the first spawn, which makes the bound easy to forget and obvious in hindsight. State all three at the registry's declaration so implementors see the full contract beside the trait, and borrowed handlers name 'reg while owned ones satisfy every region silently.
Higher-Ranked Bounds: for<'a> and the Closure Problem
Some functions must accept callbacks that work under any lifetime, not one fixed at the call site. A parser combinator applying a closure to each token cannot know how long each token's borrow lasts, and pinning the closure to a single 'a would reject every realistic parser. Higher-ranked trait bounds solve this: F: for<'a> Fn(&'a str) -> bool says the closure handles references of every lifetime, with 'a chosen fresh at each call rather than fixed once.
The contrast with a concrete bound is the heart of the matter. Writing F: Fn(&'x str) for some named 'x demands a closure tied to that specific region, which fails as soon as two tokens carry different lifetimes. The for<'a> form quantifies inside the bound: for all lifetimes 'a, the closure maps &'a str to a result. Each invocation instantiates 'a independently, so short-lived tokens and long-lived configuration strings pass through the same closure without conflict.
Function pointers exhibit the same shape naturally. A plain fn(&str) -> bool is already higher-ranked in spirit, which is why parser APIs built on fn pointers rarely hit this error. Closures only gain the property when the bound requests it, because a closure capturing &'x Config from its environment cannot honestly claim to work for lifetimes shorter than 'x. The compiler checks the capture against the bound and rejects over-promising closures with a precise region error.
The canonical production shape is a tokenizer or validator registry: struct Rules<F> where F: for<'a> Fn(&'a str) -> bool. Registration accepts any non-capturing closure or function, application feeds tokens of whatever lifetime the buffer provides, and results never borrow from the input. When results must borrow, the bound becomes for<'a> Fn(&'a str) -> Option<&'a str>, threading each token's own lifetime to its own output without cross-contamination.
Diagnosing failures means asking whether the closure captures. A closure borrowing local state can never satisfy for<'a> over regions outliving that state; the fix is moving shared state into an Rc or Arc, or narrowing the API to a single concrete lifetime when universal quantification was never needed. Name the bound you mean, and the error messages start reading like specifications.
Receiver-style verbosity distinguishes the closure traits under HRTB. The three bounds F: for<'a> Fn(&'a str), F: for<'a> FnMut(&'a str), and F: for<'a> FnOnce(&'a str) differ exactly as their single-lifetime versions do: shared, mutable, or consuming access to captured state. Parser registries usually need FnMut for counting validators; one-shot adapters need FnOnce. Choosing the weakest trait that fits keeps the most closures eligible, and the for<'a> wrapper composes with any of them.
Capturing shared state correctly is the make-or-break skill. A closure cloning an Arc<Rules> before the bound still satisfies for<'a> Fn(&'a str) -> bool, because its captures are owned and impose no region constraint. A closure borrowing a local Rules under &'x satisfies only the concrete Fn(&'x str) bound and fails universal quantification past 'x. The rule of thumb: owned or Arc-shared captures preserve higher-rankedness, while borrowed captures pin the closure to the borrowed region.
Function items coerce smoothly into higher-ranked slots. Passing a plain fn(&str) -> bool where F: for<'a> Fn(&'a str) -> bool is expected always works, since function pointers are already parametric over call-site lifetimes. This is why combinator libraries built on fn pointers sidestep the whole error family. When a closure API keeps rejecting valid-looking closures, temporarily testing with an equivalent fn item isolates whether the problem is the bound shape or the captures.
Trait method generics and HRTB solve neighboring problems. Declaring fn parse<'a>(&self, input: &'a str) -> Token<'a> on a trait gives each call its own region through ordinary method generics, which covers most parser interfaces without higher-ranked syntax. The for<'a> form becomes necessary when the bound itself must quantify: F: for<'a> Fn(&'a str) -> Token<'a> as a parameter bound, or dyn for<'a> Parser<&'a str> as an object bound. Generic associated types extend the pattern to associated types parameterized by lifetimes. Most application code stops at method generics; library authors reach for HRTB when storing or abstracting over lifetime-polymorphic behavior rather than merely calling it.
E0106 Appendix: Missing Lifetimes, Broken and Fixed
E0106 is the compiler refusing to guess. It fires when a signature needs a lifetime annotation that elision cannot supply: multiple input references with an elided output, a struct field holding a reference without a declared parameter, or a method whose elided output would silently pick the wrong source. The message names the missing specifier and usually suggests introducing a named parameter. Treat that suggestion as the start of design, not the end.
The textbook broken case is the two-argument selector. Writing fn pick(x: &str, y: bool) -> &str fails even though only one input is a reference, because integers don't carry lifetimes and the single-reference rule counts reference inputs, yet the output's source is still ambiguous once bodies return either a parameter or a constant. The canonical failing example remains longest with two &str inputs, where the compiler explicitly lists expected lifetime parameters. Both cases resolve the same way: name the relationship you intend.
The fix for selectors unifies: fn longest<'a>(x: &'a str, y: &'a str) -> &'a str. The fix for inspect-only extras keeps independence: fn with_label<'a, 'b>(v: &'a str, _label: &'b str) -> &'a str. The fix for constants is 'static: fn default_host() -> &'static str. Each fix states a different promise, and callers rely on exactly that promise, so choosing among them is API design with downstream consequences for how long callers must keep arguments alive.
Structs produce E0106's cousin when a field holds a bare reference. The declaration struct Token { text: &str } fails with a missing-lifetime diagnostic pointing at the field, fixed by struct Token<'a> { text: &'a str }. Methods returning iterators over borrowed contents hit the same shape: name the struct's parameter in the output, and the borrow chain from container through iterator to item becomes explicit.
Build a reflex: on E0106, list every reference in the signature, mark each as input or output, and write the mapping the body implements. The annotation you add is the borrowing contract your callers inherit. Getting it right here prevents E0597 downstream, because callers who see honest lifetimes keep owners alive for the right spans.
Iterator-returning methods are the second most common E0106 site. A method like fn words(&self) -> impl Iterator<Item = &str> fails because the opaque output hides an elided lifetime the compiler refuses to infer from self. Naming it as fn words<'s>(&'s self) -> impl Iterator<Item = &'s str> + 's states that the iterator borrows the receiver and yields receiver-scoped items. Callers chaining adapters preserve the region automatically.
Struct literals trigger the error at construction sites too. Building Token { text: input } where Token lacks a lifetime parameter fails with the missing specifier pointing at the struct definition, not the use. The fix declares struct Token<'a> { text: &'a str } once, after which every construction site inherits correct checking. Definition-site errors repaid once benefit every use forever, which is why the compiler points there.
Async functions add a capture flavor of the same error. An async fn borrowing its arguments captures those borrows into the returned future, and elided output futures may need explicit bounds when multiple inputs compete. The repair mirrors the sync case: name the lifetimes the future may hold, or restructure so the future owns its data. Async magnifies E0106 because the future type is invisible, but the input-output mapping question is unchanged.
Trait implementations produce E0106 when the impl's signature drifts from the trait's contract. If the trait declares fn get(&self, key: &str) -> &str with receiver elision and the impl tries to return data borrowed from an unrelated cache field, the compiler rejects the impl for incompatible lifetimes. The repair either stores the data under the receiver so the promise holds, or changes the trait to name the true source region for all implementors. This check protects every existing caller of the trait: an impl that silently lengthened or shortened the promised borrow would invalidate code the author never saw. Treat impl-level lifetime errors as contract disputes, and resolve them in the trait definition where all parties are visible.
E0597 and E0495 Appendix: Living Long Enough and Inferring Right
E0597 reports a borrow that outlives its owner: value does not live long enough. The classic shape borrows a local and returns it, or stores a request-scoped slice in a longer-lived struct. The compiler prints both spans, the borrow and the drop, and the gap between them is the bug. No annotation fixes this error, because the code's plan is unsound: the data dies while someone still holds a reference. The repair changes ownership or scope, never just spelling.
The broken canonical example builds a String inside a function and returns &str into it. The local is dropped at the function's end while the caller would use the borrow after return. Three repairs exist with different trade-offs: return the owned String and let the caller borrow from its own binding; accept a &'a str parameter and return a subslice under the same 'a so the caller supplies the owner; or extend the owner's scope by moving the String into a longer-lived container first. Each preserves the zero-copy goal differently, and the right choice depends on who should own the bytes.
E0495 is the inference counterpart: cannot infer an appropriate lifetime, usually around closures, async blocks, or trait objects where several candidate regions compete. A closure passed to a generic API may capture a borrow whose region the compiler cannot reconcile with the expected bound. The repair names the missing information: annotate closure parameter types, give the trait object an explicit + 'a, add T: 'a on the generic, or upgrade to for<'a> when the API needs universality. Where E0597 says the plan is wrong, E0495 says the plan is underspecified.
Both errors reward restructuring over annotation. Hoist the owner into the caller's scope, clone the 24-byte field at the boundary instead of borrowing it, collect iterator borrows into an owned buffer before returning, or split one function into parse-then-own stages. Measure the clone before fearing it: small owned values at boundaries routinely cost under a microsecond and delete entire error families.
Read the spans literally. E0597's two locations show the borrow and the drop; your job is moving one of them. E0495's note shows the conflicting requirements; your job is naming the intended one. In both cases the compiler has already done the hard analysis, and the fix is an ownership decision the error text is guiding you toward.
E0495 clusters around closures with unannotated parameters. A call like items.iter().map(|x| x.len()) inside generic code can fail when the compiler cannot reconcile the closure's inferred borrow with the adapter's expected region. Annotating the parameter as |x: &str| or binding the closure to a variable with an explicit Fn bound supplies the missing information. The error's note names the conflicting requirements; satisfying the narrower one explicitly resolves the inference.
Temporary lifetime extension interacts with E0597 in deceptive ways. A statement like let r = &String::from("temp") extends the temporary to the enclosing block, but let r; { r = &String::from("temp"); } does not, dropping the value at the inner scope's end. Code moved across block boundaries during refactoring silently loses extension. The repair binds the owned value in the same scope as the borrow, making the owner visible beside its uses.
Match ergonomics and borrow extension complete the toolkit. Matching on references with ref bindings or default binding modes can extend borrows unintentionally, holding owners alive across statements that need to mutate. Binding the needed fields as copies, or restructuring the match to end before mutation, releases the region. Both errors reward reading spans literally: the compiler has already computed the overlap, and the fix moves exactly one endpoint.
Loops create E0597 shapes that straight-line code never shows. Borrowing an element in one iteration and using it in the next fails when the borrow's region cannot span the back edge, typically because a mutation at the loop's end invalidates the earlier view. The repairs mirror the function case: clone the needed bytes before mutating, collect borrows into an owned buffer between phases, or restructure into indexed access that re-borrows per iteration. Iterators holding internal borrows across next() calls face the sibling problem, resolved by lending-iterator patterns or by yielding owned items. In both cases the compiler has proven the overlap across the control-flow edge, and the fix separates the phases the loop had fused.
E0502 and E0506 Appendix: Mutation While Borrowed
E0502 and E0506 guard Rust's core aliasing rule: while a shared borrow is live, no mutable borrow may observe or change the same data, and vice versa. E0502 fires when a shared borrow is used after a mutable borrow begins. E0506 fires on assignment into borrowed content. Both protect against iterator invalidation, torn reads, and the entire class of use-after-mutation bugs that plague manual memory management. Non-lexical lifetimes narrowed these errors to the actual overlap region, but adding a second pass or a cache field can still recreate them.
The canonical broken shape holds an immutable borrow across a mutation: let r = &v[0]; v.push(x); println!("{r}"). The push may reallocate, which would leave r dangling, so the compiler rejects the program. The repair depends on intent. Cloning the element before mutation is right for small Copy values. Scoping the borrow so its last use precedes the mutation satisfies the checker when ordering allows. Indexing after the mutation, or collecting needed data into locals first, removes the overlap without changing behavior.
Method-call versions of the same error hide behind getters. Calling cache.summary() to get a &str and then cache.insert(k, v) conflicts because the shared borrow from the getter is live across the mutable insert. The standard repairs are entry-style APIs that perform lookup and insertion under one mutable borrow, cloning the small summary before inserting, or restructuring into two phases: compute everything from shared borrows, end those borrows, then mutate. Each preserves the aliasing guarantee while keeping the logic intact.
Interior mutability is the escape hatch when shared-ownership mutation is genuinely required: Cell for Copy values, RefCell for dynamic borrow checking, Mutex for threads. Each moves the check or the lock somewhere explicit, with its own costs in panics or contention. Prefer restructuring first, clone second, and reach for interior mutability only when the borrowing pattern is inherent to the design rather than incidental to statement ordering.
Read these errors as overlap reports. Find the last use of the first borrow and the first use of the second, and the fix is almost always ending one before starting the other. The borrow checker isn't objecting to your algorithm; it's objecting to two live views with incompatible permissions, and narrowing either view resolves it.
Disjoint field borrows are the legitimate exception to exclusive access. Borrowing self.name immutably while assigning self.count mutably compiles, because the compiler tracks field-level permissions within one function body. Method calls can defeat this precision: self.helper() borrows all of self, blocking concurrent field access the direct version allowed. Inlining the field access or splitting the struct so independent state lives in separate fields restores the parallelism the borrow checker already understands.
Two-phase borrows cover the push-while-reading pattern. An expression like vec.push(vec.len()) compiles because the compiler treats the receiver's mutable borrow as inactive during argument evaluation, activating it only for the call. The same sequence written as two statements with an explicit let fails, since the shared borrow of vec is fully active across the mutation. Recognizing the pattern explains why some call shapes succeed where statement sequences don't.
Option::take and mem::replace move values out from behind mutable access without cloning. A loop draining a Vec<Element> via while let Some(e) = slot.take() processes each element by value while the container stays borrowed mutably but never aliased. Entry APIs apply the same idea to maps: a single mutable borrow performs lookup and insertion atomically from the checker's perspective. These patterns convert borrow-then-mutate conflicts into ownership transfers the rules accept unconditionally.
Match guards and entry APIs round out the overlap toolkit. A guard like Some(v) if cache.contains(&k) borrows the map inside the guard while the arm body mutates it, reproducing E0502 through pattern syntax. Binding the lookup result before the match, or restructuring with if-let chains that end each borrow, clears the conflict. For maps specifically, the entry API performs lookup and insertion under one mutable borrow, which is both the fastest and the only borrow-correct phrasing for vacant-or-occupied logic. Recognizing guard borrows as ordinary shared borrows with pattern-sugar scoping turns a confusing error into the familiar overlap report with the same three repairs: scope, clone, or restructure.
A Zero-Copy Parser Held a Request Buffer Past Its Drop in Production
- An E0597 you refactor away without understanding is a bug you promoted. The compiler pointed at the exact over-long storage; threading lifetimes until the error disappears preserves the defect while deleting the warning. Clone the 24 bytes at the boundary and move on.
- Zero-copy parsing needs lifetime-parameterized owners, not longer lifetimes. Give the batch struct its own arena lifetime so request-scoped borrows cannot physically be stored in it, instead of unifying everything under one 'a that must cover the longest-lived field.
- Recycled buffers turn stale borrows into silent aliasing that no length check catches reliably. Poison buffers in test builds, stress reuse across 10,000 requests, and measure the clone you were afraid of: 0.4 microseconds at this throughput was invisible.
| File | Command / Code | Purpose |
|---|---|---|
| src | fn first_line(doc: &str) -> &str { | Elision Rule One |
| src | struct Table { | Elision Rules Two and Three |
| src | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { | Multiple Input Lifetimes |
| src | use std::thread; | 'static Demystified |
| src | struct View<'a> { | Struct Lifetime Parameters and a Working Variance Primer |
| src | use std::fmt::Display; | Trait Bounds With Lifetimes |
| src | fn apply_all<F>(tokens: &[String], mut f: F) -> usize | Higher-Ranked Bounds |
| src | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { | E0106 Appendix |
| src | fn owned_greeting(name: &str) -> String { | E0597 and E0495 Appendix |
| src | use std::collections::HashMap; | E0502 and E0506 Appendix |
Key takeaways
Common mistakes to avoid
7 patternsSprinkling 'a until E0106 disappears instead of mapping inputs to outputs
Using 'static to silence a borrow error on per-request data
Storing a request-scoped borrow in a longer-lived struct field
Assuming trait objects default to the surrounding lifetime
Binding a closure to one concrete lifetime when the API needs for<'a>
Holding a shared borrow across a mutation, then reaching for RefCell first
Putting borrowed data inside Cell, Mutex, or &mut and fighting invariance
Interview Questions on This Topic
State the three lifetime elision rules and give a signature each one governs.
Frequently Asked Questions
20+ years shipping production backend systems. Everything here is grounded in real deployments.
That's Core. Mark it forged?
28 min read · try the examples if you haven't