What Is Variance in Rust Lifetimes?

Variance in Rust lifetimes defines how generic types relate to their lifetime parameters, allowing the compiler to safely substitute longer lifetimes for shorter ones.

When a longer lifetime refuses to fit

You write a function that accepts a slice of string references. You pass in a slice where each string lives in a different scope. The compiler accepts it without complaint. You change the slice to hold mutable references instead. The exact same code suddenly fails with a lifetime mismatch error. The compiler insists the references must live for the exact same duration. You did not change the logic. You only changed & to &mut. The rulebook changed underneath you.

That rulebook is variance. Variance is the hidden contract that tells the compiler whether a type parameter with a longer lifetime can safely substitute for a shorter one, or vice versa. Rust calculates this automatically for every struct, enum, and function you write. You rarely need to invoke the word in conversation, but understanding it explains why some generic code compiles effortlessly while other code demands exact lifetime matches.

The clearance badge analogy

Think of lifetimes as security clearances. 'static is the highest clearance. Any shorter lifetime is a lower clearance. Variance determines whether you can swap badges at the door.

Covariant positions accept higher clearances in place of lower ones. If a room requires clearance level 3, a person with level 5 can enter. The longer lifetime safely covers the shorter requirement.

Contravariant positions work backward. They accept lower clearances in place of higher ones. If a room requires clearance level 5, a person with level 3 can enter. This happens when the type consumes the lifetime rather than exposing it.

Invariant positions demand an exact match. Level 3 requires level 3. No substitutions allowed. The compiler enforces this when mutation or interior mutability could break memory safety if lifetimes were swapped.

Rust assigns each lifetime parameter to one of these three categories based on how the parameter appears inside the type. You do not declare variance manually. The compiler infers it from usage patterns.

Seeing variance in action

You can inspect the compiler's decision using an unstable attribute. This attribute is not part of stable Rust, but it is invaluable for learning how the type system classifies your definitions.

// Unstable attribute: only works with nightly or specific compiler flags.
// It prints the inferred variance to stderr during compilation.
#[rustc_dump_variances]
struct Wrapper<'a> {
    // The field holds an immutable reference.
    // Immutable references only read data.
    // Reading never extends or shortens the data's lifetime.
    data: &'a i32,
}

// The compiler will output: Wrapper is covariant over 'a.
// Covariant means a longer lifetime can replace a shorter one.
// This matches the intuition that immutable borrows are flexible.

Compile this with rustc +nightly variance.rs and check the standard error output. You will see the compiler explicitly label 'a as covariant. The attribute does not change behavior. It only reveals the internal classification.

How the compiler decides

Variance flows from how a lifetime parameter is used inside a type definition. The compiler tracks three patterns.

Covariance appears when the lifetime is used in output positions. Immutable references, function return types, and trait objects fall here. If a type only exposes data through a lifetime, giving it a longer lifetime never breaks safety. The data lives longer than anyone expects, which is always safe.

Contravariance appears when the lifetime is used in input positions. Function arguments and closure parameters fall here. If a type consumes data tied to a lifetime, giving it a shorter lifetime is safe. The function finishes before the shorter lifetime expires, so no dangling references escape.

Invariance appears when the lifetime is used in both input and output positions, or when mutation is involved. Mutable references, Cell, and RefCell fall here. If a type can both read and write through a lifetime, swapping lifetimes could create aliasing violations or use-after-free bugs. The compiler locks the lifetime to an exact match.

This classification happens automatically. You define the struct, and the compiler traces the data flow. The result determines whether your generic code accepts flexible lifetimes or demands strict alignment.

Variance in real code

Consider a collection that stores references to configuration values. You want to gather references from different modules, each with its own scope.

use std::collections::HashMap;

/// Gathers configuration references into a single map.
/// The map owns the keys, but borrows the values.
fn collect_configs<'a>(
    configs: Vec<(&'a str, &'a i32)>,
) -> HashMap<&'a str, &'a i32> {
    // The HashMap stores immutable references.
    // Immutable references are covariant over their lifetime.
    // The compiler allows mixing references with different scopes.
    configs.into_iter().collect()
}

fn main() {
    // First config lives for the entire main function.
    let global_val = 42;
    let global_ref = &global_val;

    // Second config lives only inside this block.
    let local_val = 99;
    let local_ref = &local_val;

    // The vector requires a single lifetime parameter.
    // Covariance lets the compiler pick the shorter lifetime.
    // Both references satisfy the shorter bound.
    let mixed = vec![
        ("global", global_ref),
        ("local", local_ref),
    ];

    let map = collect_configs(mixed);
    println!("{:?}", map);
}

The code compiles because &'a T is covariant. The compiler sees that the shorter lifetime covers both references safely. Now change the values to mutable references.

// This version fails to compile.
// Mutable references are invariant over their lifetime.
// The compiler refuses to mix scopes.
fn collect_configs_mut<'a>(
    configs: Vec<(&'a str, &'a mut i32)>,
) -> HashMap<&'a str, &'a mut i32> {
    configs.into_iter().collect()
}

The compiler rejects this with a lifetime mismatch error. &'a mut T is invariant. The compiler cannot guarantee that swapping a longer lifetime for a shorter one will not create two mutable aliases pointing to overlapping scopes. The exact match requirement protects memory safety. You must restructure the code to use owned values, or isolate the mutable borrows in separate scopes.

When variance bites you

Variance errors rarely announce themselves as "variance mismatch." They surface as lifetime bound violations. The compiler complains that a reference does not live long enough, or that a value was moved while borrowed.

The most common manifestation is E0597 (borrowed value does not live long enough). This error appears when you try to store a short-lived reference in a covariant container that the compiler has forced to a longer bound. The compiler picks the longest required lifetime across all uses. If one use demands 'static and another provides a stack reference, the shorter reference fails the bound.

Another frequent trigger is E0621 (explicit lifetime required in the trait definition). This happens when you implement a trait that expects a covariant lifetime, but your struct contains an invariant field. The compiler cannot satisfy the trait's flexibility requirement. You must either remove the invariant field, or adjust the trait bound to accept exact lifetimes.

Variance also explains why trait objects behave differently from generic parameters. dyn Trait + 'a is covariant over 'a. You can pass a longer-lived trait object where a shorter one is expected. This flexibility enables runtime polymorphism without forcing exact lifetime matches. Generic parameters, however, default to invariance unless the compiler proves covariance. This difference catches many developers when migrating from generics to trait objects.

Convention note: The Rust community rarely discusses variance in daily standups. It is an implementation detail that the compiler handles automatically. You only need to understand it when the borrow checker rejects code that looks logically sound. Treat variance as the compiler's internal routing table. When it blocks you, check whether mutation or interior mutability is forcing invariance.

Choosing your lifetime positions

Design your types with variance in mind. The compiler will enforce the rules, but you can predict them by following these patterns.

Use &'a T when you only need to read data and want maximum flexibility across scopes. Covariance lets callers pass references with varying lifetimes without restructuring their code.

Use &'a mut T when you need exclusive write access and are willing to accept strict lifetime alignment. Invariance guarantees no hidden aliases exist, but it forces callers to keep borrows in a single scope.

Use fn(&'a T) or closure arguments when your function consumes data tied to a lifetime. Contravariance allows callers to pass shorter-lived references safely, since the function finishes before the lifetime expires.

Use Cell<&'a T> or RefCell<&'a T> when you need interior mutability, but expect the compiler to lock the lifetime to an exact match. Invariance prevents mutation from creating dangling pointers across scope boundaries.

Reach for owned types like String or Vec<T> when variance restrictions make your API too rigid. Ownership removes lifetime parameters entirely, trading memory allocation for ergonomic flexibility.

Trust the compiler's variance inference. It rarely makes mistakes, and fighting it usually means accepting stricter bounds than necessary.

Where to go next