Skip to content

Instantly share code, notes, and snippets.

@carlosgrillet
Created March 23, 2026 17:05
Show Gist options
  • Select an option

  • Save carlosgrillet/ebaf0532ad803f2c6f68e2b8cb78e931 to your computer and use it in GitHub Desktop.

Select an option

Save carlosgrillet/ebaf0532ad803f2c6f68e2b8cb78e931 to your computer and use it in GitHub Desktop.
Rust Ownership: The Complete Mental Model

Rust Ownership: The Complete Mental Model

Rust rules are simple to understand but can confusing to implement.

Part 1: ELI5 (Explain Like I'm 5)

The Apartment Analogy

Think of every value in Rust as a physical apartment key — not a copy, the only key.

Rule 1: Every apartment has exactly one key holder

let s = String::from("hello");

You (s) hold the key to apartment "hello". You can go inside, rearrange furniture, whatever you want.

Rule 2: If you give the key away, you can't get in anymore

let s2 = s;  // You handed the key to s2
// You (s) no longer have the key. You can't open the door.

This is a move. You didn't copy the key — you handed it over. Now s2 lives there, and s is locked out. This is what catches most people: in Go, Python, or Java, s2 = s makes a copy of the reference and both still work. In Rust, the original is gone.

Rule 3: You can let someone borrow the key

Instead of giving the key away permanently, you can lend it:

  • Shared borrow (&): "Here, take a photo of the apartment, but don't move anything." Multiple friends can take photos at the same time.
  • Exclusive borrow (&mut): "Here's the key, rearrange the furniture, but I need it back, and nobody else can come in while you're remodeling."

Rule 4: When the key holder leaves town, the apartment is demolished

When the variable that owns the value goes out of scope, Rust destroys the value. No garbage collector needed — ownership is the cleanup strategy.

Why Rust does this

Imagine a world where anyone can copy apartment keys freely. Two people try to demolish the same apartment. Someone walks into a demolished building. Someone remodels while another person is inside.

These are use-after-free, double-free, and data races — the bugs that plague C and C++. Go avoids some of these with a garbage collector. Rust avoids all of them at compile time, with zero runtime cost.

Part 2: The Technical Explanation

2.1 Ownership and Moves

When you assign a heap-allocated value to another variable, Rust moves it. The original binding is invalidated.

// ✅ This compiles
fn main() {
    let s1 = String::from("hello");
    let s2 = s1;       // s1 is MOVED into s2
    println!("{s2}");   // fine — s2 owns it
}
// ❌ This does NOT compile
fn main() {
    let s1 = String::from("hello");
    let s2 = s1;        // s1 is moved
    println!("{s1}");    // ERROR: borrow of moved value: `s1`
}

Key insight for Go developers: In Go, s2 := s1 for a slice or string copies the header (pointer + len + cap), and both variables remain valid because the GC tracks references. In Rust, there's no GC — so after the move, the compiler forbids using the original to prevent double-free.

Copy types: the exception

Simple stack-only types like integers, booleans, and floats implement the Copy trait. For these, assignment copies the bits, and both variables stay valid:

// ✅ This compiles — integers are Copy
fn main() {
    let x = 42;
    let y = x;      // x is COPIED, not moved
    println!("{x}"); // fine
    println!("{y}"); // fine
}

The rule of thumb: if a type manages heap memory (like String, Vec, HashMap), it moves. If it's a simple stack value (like i32, bool, f64), it copies.

2.2 Borrowing: References

Instead of transferring ownership, you can borrow a value with a reference.

Immutable (shared) references: &T

Multiple readers, no writers.

// ✅ Multiple immutable borrows — fine
fn main() {
    let s = String::from("hello");
    let r1 = &s;
    let r2 = &s;
    println!("{r1}, {r2}"); // both work
}

Mutable (exclusive) references: &mut T

One writer, no readers.

// ✅ One mutable borrow — fine
fn main() {
    let mut s = String::from("hello");
    let r = &mut s;
    r.push_str(", world");
    println!("{r}");
}
// ❌ Two mutable borrows at the same time — does NOT compile
fn main() {
    let mut s = String::from("hello");
    let r1 = &mut s;
    let r2 = &mut s;    // ERROR: cannot borrow `s` as mutable more than once
    println!("{r1}, {r2}");
}
// ❌ Mixing mutable and immutable borrows — does NOT compile
fn main() {
    let mut s = String::from("hello");
    let r1 = &s;           // immutable borrow
    let r2 = &mut s;       // ERROR: cannot borrow `s` as mutable
    println!("{r1}, {r2}"); // because `r1` is still alive here
}

NLL (Non-Lexical Lifetimes): borrows end at last use, not at }

// ✅ This compiles — the immutable borrows are done before the mutable one starts
fn main() {
    let mut s = String::from("hello");
    let r1 = &s;
    let r2 = &s;
    println!("{r1}, {r2}"); // <-- r1 and r2's last use, borrows end here

    let r3 = &mut s;        // fine — no active immutable borrows
    r3.push_str("!");
    println!("{r3}");
}

2.3 Ownership in Function Calls

Passing a value to a function moves it (unless it's Copy). Returning a value moves it back to the caller.

// ✅ Ownership transfers through function calls
fn take_ownership(s: String) {
    println!("I own: {s}");
} // `s` is dropped here

fn main() {
    let s = String::from("hello");
    take_ownership(s);
    // s is moved — can't use it anymore
}
// ❌ Using a value after it was moved into a function
fn take_ownership(s: String) {
    println!("I own: {s}");
}

fn main() {
    let s = String::from("hello");
    take_ownership(s);
    println!("{s}");  // ERROR: borrow of moved value: `s`
}

To let a function read without taking ownership, pass a reference:

// ✅ Borrowing — caller keeps ownership
fn print_it(s: &String) {
    println!("Borrowed: {s}");
}

fn main() {
    let s = String::from("hello");
    print_it(&s);
    println!("Still mine: {s}"); // fine — we only lent it
}

Returning values: moving ownership back to the caller

When a function returns a value, ownership moves out to wherever the return value lands. This is how you create something inside a function and give it to the outside world.

// ✅ Returning an owned value — ownership moves to the caller
fn create_greeting(name: &str) -> String {
    let greeting = format!("Hello, {name}!");
    greeting  // ownership of this String moves to the caller
}

fn main() {
    let msg = create_greeting("Carlos");
    println!("{msg}"); // msg owns the String now
}

A common pattern: take ownership, transform, and return ownership back. This is Rust's way of saying "I need to modify this, but I'll give it back."

// ✅ Take ownership, modify, return ownership
fn add_exclamation(mut s: String) -> String {
    s.push('!');
    s  // give it back
}

fn main() {
    let s = String::from("hello");
    let s = add_exclamation(s);  // s moves in, new s comes back
    println!("{s}"); // hello!
}

In Go, you'd just pass a pointer and modify in place. Rust can do that too — and it's often preferred, because the borrow communicates intent more clearly:

// ✅ Borrow mutably instead of moving — often cleaner
fn add_exclamation(s: &mut String) {
    s.push('!');
}

fn main() {
    let mut s = String::from("hello");
    add_exclamation(&mut s);  // we keep ownership, function just modifies
    println!("{s}"); // hello!
}

Returning references: you must prove they're valid

You can return a reference, but the compiler requires a lifetime guarantee — the data the reference points to must outlive the reference itself.

// ❌ Dangling reference — does NOT compile
fn broken() -> &String {
    let s = String::from("hello");
    &s  // ERROR: `s` is dropped at end of function, reference would dangle
}
// ✅ Returning a reference to data you RECEIVED — it outlives the function
fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();
    for (i, &b) in bytes.iter().enumerate() {
        if b == b' ' {
            return &s[..i];
        }
    }
    s
}

fn main() {
    let sentence = String::from("hello world");
    let word = first_word(&sentence); // word borrows from sentence
    println!("First word: {word}");   // fine — sentence is still alive
}

The key rule: you can only return a reference to something that existed before the function was called — something the caller passed in. You can never return a reference to a local variable, because locals die when the function ends.

Think of it like this: if the function created the apartment and then tried to hand you a photo of the inside — but the apartment was demolished the moment the function ended — your photo would show a demolished building. Rust won't let that happen.

Returning multiple values with tuples

Rust doesn't have Go-style multiple return values, but tuples achieve the same thing. This is handy for returning ownership alongside some computed result:

// ✅ Return a tuple to give back ownership + extra data
fn measure(s: String) -> (String, usize) {
    let len = s.len();
    (s, len)  // give back ownership AND the length
}

fn main() {
    let s = String::from("hello");
    let (s, len) = measure(s);
    println!("{s} has {len} bytes"); // hello has 5 bytes
}

Though in practice, if you just need the length, borrowing is cleaner:

// ✅ Prefer borrowing when you don't need to consume the value
fn measure(s: &str) -> usize {
    s.len()
}

fn main() {
    let s = String::from("hello");
    let len = measure(&s);
    println!("{s} has {len} bytes"); // no ownership gymnastics needed
}

Summary: function return types and what they mean

Return type What happens
T Caller receives ownership of a new or moved value
&T Caller gets a read-only view — data must outlive it
&mut T Caller gets a writable view — data must outlive it
Option<T> Caller may or may not get an owned value
Option<&T> Caller may or may not get a reference (like HashMap::get)
Result<T, E> Caller gets an owned value or an owned error

2.4 Ownership in Structs and self

This is where it starts to click for the patterns you saw in the book.

When a method takes self, &self, or &mut self, it's deciding how it relates to the struct's ownership:

Signature Meaning
self Takes ownership — consumes the struct
&self Borrows immutably — reads, struct stays alive
&mut self Borrows mutably — can modify, struct stays alive
struct Inventory {
    items: Vec<String>,
}

impl Inventory {
    // &self — just reading
    fn count(&self) -> usize {
        self.items.len()
    }

    // &mut self — modifying
    fn add(&mut self, item: String) {
        self.items.push(item);
    }

    // self — consuming (the inventory is gone after this)
    fn into_sorted(self) -> Vec<String> {
        let mut items = self.items;
        items.sort();
        items
    }
}

fn main() {
    let mut inv = Inventory { items: vec![] };
    inv.add(String::from("sword"));     // &mut self
    println!("count: {}", inv.count()); // &self
    let sorted = inv.into_sorted();     // self — inv is consumed
    // inv is gone here — moved into into_sorted
    println!("{sorted:?}");
}

2.5 Demystifying the Confusing Lines

Now let's decode the exact patterns that confused you.

Pattern 1: *self.stock.entry(shirt).or_insert(0) += 1

Let's build up to it:

use std::collections::HashMap;

#[derive(Debug, PartialEq, Eq, Hash, Clone)]
enum ShirtColor {
    Red,
    Blue,
}

struct Shop {
    stock: HashMap<ShirtColor, u32>,
}

impl Shop {
    fn add_shirt(&mut self, color: ShirtColor) {
        // Step by step:
        // self.stock             — access the HashMap through &mut self
        // .entry(color)          — returns an Entry enum (Occupied or Vacant)
        // .or_insert(0)          — if vacant, insert 0; returns &mut u32
        // * ... += 1             — dereference the &mut u32 to modify the value

        *self.stock.entry(color).or_insert(0) += 1;
    }
}

fn main() {
    let mut shop = Shop { stock: HashMap::new() };
    shop.add_shirt(ShirtColor::Red);
    shop.add_shirt(ShirtColor::Red);
    shop.add_shirt(ShirtColor::Blue);
    println!("{:?}", shop.stock);
    // {Red: 2, Blue: 1}
}

Why the *? or_insert(0) returns &mut u32 — a mutable reference to the value inside the HashMap. To actually change the number, you dereference it with * to get the u32 itself, then add 1.

Think of it in Go terms: or_insert gives you back a pointer *uint32. You need to dereference it to write through it. Rust just makes this explicit.

Pattern 2: self.stock.get(&ShirtColor::Red).unwrap_or(&0)

use std::collections::HashMap;

#[derive(Debug, PartialEq, Eq, Hash)]
enum ShirtColor {
    Red,
    Blue,
}

struct Shop {
    stock: HashMap<ShirtColor, u32>,
}

impl Shop {
    fn red_count(&self) -> &u32 {
        // Step by step:
        // self.stock                   — access the HashMap through &self
        // .get(&ShirtColor::Red)       — returns Option<&u32>
        //    - Some(&u32) if the key exists
        //    - None if it doesn't
        // .unwrap_or(&0)               — if None, return a reference to 0 instead

        self.stock.get(&ShirtColor::Red).unwrap_or(&0)
    }
}

fn main() {
    let mut shop = Shop { stock: HashMap::new() };
    shop.stock.insert(ShirtColor::Red, 5);

    println!("Red: {}", shop.red_count());    // Red: 5
    println!("Blue: {}", shop.stock.get(&ShirtColor::Blue).unwrap_or(&0)); // Blue: 0
}

Why &ShirtColor::Red and &0?

  • HashMap::get takes a reference to the key (&K), because it just needs to look at it, not own it. So you pass &ShirtColor::Red.
  • get returns Option<&V> — a reference to the value, not the value itself (the HashMap keeps ownership).
  • unwrap_or needs a fallback of the same type: &u32. So you pass &0.

In Go, this would be like stock[ShirtColor.Red] which returns the zero value automatically. Rust makes you choose what happens when the key is missing — that's what unwrap_or does.

2.6 Common Patterns That Trip People Up

Returning a reference to a local value

(Covered in detail in section 2.3 — here's the short version.)

// ❌ Dangling reference — does NOT compile
fn make_greeting() -> &String {
    let s = String::from("hello");
    &s  // ERROR: `s` is dropped at end of function, reference would dangle
}
// ✅ Return the owned value instead
fn make_greeting() -> String {
    let s = String::from("hello");
    s   // ownership moves to the caller
}

Iterating and modifying

// ❌ Can't immutably iterate and mutably modify at the same time
fn main() {
    let mut v = vec![1, 2, 3];
    for item in &v {
        v.push(*item * 2);  // ERROR: cannot borrow `v` as mutable
    }
}
// ✅ Collect first, then modify
fn main() {
    let mut v = vec![1, 2, 3];
    let doubled: Vec<i32> = v.iter().map(|x| x * 2).collect();
    v.extend(doubled);
    println!("{v:?}"); // [1, 2, 3, 2, 4, 6]
}

Clone: the escape hatch

When you really do need two independent copies:

// ✅ Clone creates a deep copy — both variables own independent data
fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();   // deep copy — s1 is NOT moved
    println!("{s1}");       // fine
    println!("{s2}");       // fine
}

Clone is like Go's implicit copy behavior — but in Rust you pay for it explicitly.

Part 3: The Mental Checklist

When you see Rust code and feel confused, ask these questions in order:

  1. Who owns this value? Find the variable that was last assigned/moved into.
  2. Is this a move or a borrow? If there's & or &mut, it's a borrow. Otherwise, it's a move (unless the type is Copy).
  3. Is the borrow shared or exclusive? &T = shared (many readers). &mut T = exclusive (one writer).
  4. When does the borrow end? At the last use of the reference, not at }.
  5. Do I need to dereference? If you have &mut T and want to assign to the inner T, you need *.

Part 4: Quick Reference — Go vs Rust

Concept Go Rust
Heap allocation GC tracks references Single owner, dropped at scope end
Assignment of struct Copies the struct Moves (unless Copy)
Passing to function Copies (or passes pointer) Moves (or borrows with &)
Multiple readers Just do it (runtime safety) &T references (compile-time check)
One writer Just do it (hope for best) &mut T (compiler enforced)
Concurrent access Mutex / channels / -race Ownership + Send/Sync traits
Null/nil nil Option<T> (no null)
Map access for missing Returns zero value Returns Option, you decide

The ownership system isn't fighting you — it's telling you about bugs you didn't know you had. Once you internalize the model, the compiler stops being an adversary and becomes the best code reviewer you've ever worked with.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment