Skip to content
rusthacks.dev
Rust

Ownership in Rust Explained With a Mental Model That Holds

Ownership is three rules the compiler checks for every value. This post builds a mental model from them and uses it to predict what compiles before you run the compiler.

8 min read
Summarize in AI
Flat illustration for the post “Ownership in Rust Explained With a Mental Model That Holds”, composed around tooling.

Ownership is the idea that makes Rust different, and it is usually taught as a list of rules to memorize. That works until you meet a situation the rules do not obviously cover. A mental model works better: every value in a Rust program has exactly one owner, the owner is responsible for cleaning the value up, and everything else in the language is a way of letting other code use a value without taking that responsibility. This post builds that model from one runnable program that prints when each value is cleaned up, so you can watch the owner do its job.

The program ran on Rust 1.99 in edition 2024. Every line of output below is what it printed, and the errors are the compiler's own.

The model in three sentences

A value has one owner, a variable or a field that is responsible for it. When the owner goes out of scope, the value is dropped, meaning its destructor runs and its memory is freed. Passing a value to a function by value, or assigning it to another variable, moves the responsibility to the new owner, and the old name can no longer be used.

Everything else, references, borrowing, lifetimes, Clone, Rc, follows from those sentences. References let code look at a value without owning it. Lifetimes are the compiler's proof that a reference does not outlive its owner. Clone makes a second value with its own owner. Rc makes ownership shared on purpose, with a count of owners. If you can state the owner of every value in a function, you can predict what the compiler will accept.

Watching ownership happen

To see owners and drops, the program defines a type that prints a message when it is dropped, and moves it through five situations:

src/main.rsrust
struct Resource(&'static str);

impl Drop for Resource {
    fn drop(&mut self) {
        println!("  dropped {}", self.0);
    }
}

fn take(r: Resource) {
    println!("  take() has {}", r.0);
} // r is dropped here: take() was the owner

fn borrow(r: &Resource) {
    println!("  borrow() sees {}", r.0);
}

fn make(name: &'static str) -> Resource {
    println!("  making {name}");
    Resource(name)
}

fn main() {
    println!("1. scope ends drop");
    {
        let _a = make("a");
        println!("  end of block");
    }

    println!("2. a move transfers ownership");
    let b = make("b");
    take(b);
    println!("  back in main, b is gone");

    println!("3. a borrow does not");
    let c = make("c");
    borrow(&c);
    borrow(&c);
    println!("  c still usable");

    println!("4. reassignment drops the old value");
    let mut d = make("d1");
    d = make("d2");
    println!("  d is now {}", d.0);

    println!("5. locals drop in reverse order");
    let _e = make("e");
    let _f = make("f");
    println!("  end of main");
}
Text
1. scope ends drop
  making a
  end of block
  dropped a
2. a move transfers ownership
  making b
  take() has b
  dropped b
  back in main, b is gone
3. a borrow does not
  making c
  borrow() sees c
  borrow() sees c
  c still usable
4. reassignment drops the old value
  making d1
  making d2
  dropped d1
  d is now d2
5. locals drop in reverse order
  making e
  making f
  end of main
  dropped f
  dropped e
  dropped d2
  dropped c

Read the output against the code, one situation at a time.

Situation one: a is created inside a block and dropped at the closing brace, after "end of block" prints. The owner's scope ended, so the value was cleaned up at that exact line. There is no garbage collector deciding when; the drop point is visible in the source, and the output confirms it. This is the property that makes Rust's destructors reliable for files, locks and network connections: a File closes when its owner goes out of scope, every time, even if the function returns early with ?.

Situation two: take(b) moves b into the function. Inside, take is the owner, and dropped b prints when the function ends, before control returns to main. By the time "back in main" prints, b is already gone, and writing b after the call would be a compile error. That is the model working: after a move, the old name owns nothing.

Situation three: borrow(&c) passes a reference. The function can look at c but is not the owner, so nothing is dropped when it returns, and c is still usable. The two calls print twice and c survives both. Borrowing is how you let a function use a value without giving it away.

Situation four: assigning to d a second time drops the first value immediately, which is why dropped d1 appears right after making d2 and before the next print. A variable can only own one value, so replacing the value ends the old one.

Situation five: the end of main drops its remaining locals in reverse order of declaration: f, then e, then d2, then c. Reverse order matters because later variables may borrow from earlier ones, so the earlier ones must outlive them. The value b is not in this list. It was moved into take and dropped there.

What the compiler enforces

The compiler checks the model at compile time, and each rule corresponds to an error you will meet. Using a value after moving it is E0382:

Text
error[E0382]: borrow of moved value: `name`
 --> e0382a.rs:8:29
  |
6 |     let name = String::from("ferris");
  |         ---- move occurs because `name` has type `String`, which does not implement the `Copy` trait
7 |     let n = consume(name);
  |                     ---- value moved here
8 |     println!("{n} bytes in {name}");
  |                             ^^^^ value borrowed here after move

The three marked lines are the declaration, the move and the late use. If you can say who owned the string at each point, the error is not mysterious: main owned it on line 6, consume owned it from line 7, and main tried to use it on line 8 after giving it away.

You also cannot take ownership of something you only borrowed. This is E0507, "cannot move out of ... behind a shared reference":

Text
error[E0507]: cannot move out of `r.name` which is behind a shared reference
 --> e0507.rs:8:17
  |
8 |     let taken = r.name;
  |                 ^^^^^^ move occurs because `r.name` has type `String`, which does not implement the `Copy` trait
  |
help: consider borrowing here
  |
8 |     let taken = &r.name;
help: consider cloning the value if the performance cost is acceptable
  |
8 |     let taken = r.name.clone();

A reference to a Holder means someone else owns it. Taking its name out would leave the real owner with a hole. The compiler offers the two sound alternatives: borrow the field as well, or clone it so that the new owner has its own copy.

Three questions that resolve most problems

When ownership code does not compile or you are unsure how to write it, ask these in order.

Who should own this value? Give the data one clear owner, usually the part of the program that decides how long it lives. A configuration object is owned by main, a request body by the handler, a cache by the service.

Does this function need to own it, or only look at it? A function that only reads takes &T or &str. A function that stores the value, sends it to another thread or returns it needs T. Writing the right parameter type is the biggest single skill in everyday Rust.

If several parts need it at once, is that sharing or copying? Copying means .clone() and each part has its own value. Sharing means Rc<T> on one thread or Arc<T> across threads, each part holds a counted handle to one value, and the value is dropped when the last handle goes. The choice is cost and semantics: cloning a large vector copies it, cloning an Arc increments a number.

Where the model has edges

Some types do not move: integers, floats, bool, char and references implement Copy, so assigning them duplicates the bits and both names stay valid. That is why let b = a; works for a number and moves a string. Copy versus Clone covers exactly where the line sits.

Ownership is also per value, not per variable name. A struct that owns a String field owns it through the struct: when the struct is dropped, the field is dropped. A Vec<String> owns all of its strings. A Box<T> owns a value on the heap. The model composes: dropping an owner drops everything it owns, recursively, which is why a Rust program needs no manual cleanup of nested data.

References add one rule on top. You can have either any number of shared references or exactly one mutable reference to a value at a time, and a reference must not outlive its owner. The sibling errors are E0502, E0499 and E0597, and they are all this one rule seen from different angles.

Why Rust does it this way

The alternatives each have a cost. A garbage collector frees memory at times you do not control and pays for tracking at run time. Manual malloc and free give control and let you free twice, free too early or forget. Reference counting everywhere is simple and costs an atomic increment per copy and cannot handle cycles. Ownership gives deterministic cleanup with no runtime cost, at the price of stating who owns what, which is the cost this whole post is about. In return, the same rules that free memory also rule out data races: a value that can be changed by only one owner at a time cannot be changed by two threads at once.

When the model does not fit, as in a graph where nodes refer to each other, the usual answer is to store indices or IDs into a Vec instead of references, or to reach for Rc<RefCell<T>> with eyes open about the run-time checks it introduces. For the day-to-day patterns that follow from the model, see Copy versus Clone and the error E0382 in detail.

Taggedownership
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.