Ownership and Borrowing in Practice, A Working Mental Model
TL;DR — Ownership is about who’s responsible for cleanup, not about who can read the data / Borrows are temporary views with a compile-time lifetime contract, and the compiler is right almost always / If you find yourself fighting the borrow checker, the bug is usually in your data structure, not in your understanding of references
Most Rust tutorials introduce ownership with String vs &str and a diagram of stack and heap. That’s not wrong, but it’s not the mental model I actually use when writing code. After enough production Rust I’ve collapsed the rules down to a smaller set of questions I ask while typing, and the borrow checker mostly stays out of my way.
This post is the model that worked for me. It’s aimed at engineers who’ve read the book and written a few hundred lines of Rust but still feel like the compiler is fighting them. If that’s you, the fix is rarely “learn more about lifetimes.” It’s almost always “structure the data differently.”
If you haven’t read it yet, my previous post on why Rust is growing covers the context for why this learning curve is worth it.
The actual mental model
Forget the textbook definitions for a moment. When I’m writing Rust, every value in scope falls into one of three buckets:
- I own it. It’s mine to use, mutate, hand off, or drop. When my scope ends, it goes away.
- I’m borrowing it immutably (
&T). I can read it. I can pass the borrow further down. I can’t change it. I can’t outlive the owner. - I’m borrowing it mutably (
&mut T). I can read and write. I’m the only one who has access right now (no other&or&mutexists). I can’t outlive the owner.
The rules — one mutable borrow xor any number of immutable borrows, and no borrow outlives the owner — are not arbitrary. They’re the contract that makes “no data races, no use-after-free” a compile-time guarantee. Once you internalize them as the contract you’re signing, the error messages start reading like helpful feedback rather than nagging.
fn main() {
let mut users = vec!["alice", "bob"];
let first = &users[0]; // immutable borrow
// users.push("carol"); // ERROR: can't mutate while `first` is alive
println!("{first}"); // last use of `first`
users.push("carol"); // OK: borrow is over
}
Note that “last use” is enough — non-lexical lifetimes (stable since 2018) mean the borrow ends at the last read, not at the closing brace. This single feature removes about 60% of what people think of as “fighting the borrow checker.”
Pattern 1: Take by value when you’ll consume it
If a function is going to fully consume or transform a value, take it by value. Don’t take &T and then clone inside. This shows up constantly in builder-style APIs.
// Wrong: forces caller to keep ownership, then we clone anyway
fn build_request(url: &String, body: &Vec<u8>) -> Request {
Request::new(url.clone(), body.clone())
}
// Right: take ownership, no clone needed
fn build_request(url: String, body: Vec<u8>) -> Request {
Request::new(url, body)
}
The flip side: if you only need to read, take &str or &[u8], not &String or &Vec<u8>. The first is more general — it accepts both owned and borrowed forms — and avoids an extra layer of indirection.
// Most general read-only signature
fn parse_header(line: &str) -> Option<(&str, &str)> {
line.split_once(':').map(|(k, v)| (k.trim(), v.trim()))
}
The return type here is interesting: it borrows from the input. The compiler infers the lifetimes — the returned &str slices are valid as long as line is. That’s almost always what you want for parsing.
Pattern 2: When you need mutation, structure to avoid sharing
This is the rule that took me longest. Most “I need Arc<Mutex<T>>” situations are actually “I structured my data wrong.” When I find myself reaching for shared mutable state, I stop and ask: can one task own this and respond to messages from the others?
use tokio::sync::mpsc;
// Instead of Arc<Mutex<Cache>> shared across tasks...
pub struct CacheActor {
inner: HashMap<String, Vec<u8>>,
rx: mpsc::Receiver<CacheMsg>,
}
pub enum CacheMsg {
Get { key: String, reply: tokio::sync::oneshot::Sender<Option<Vec<u8>>> },
Set { key: String, value: Vec<u8> },
}
impl CacheActor {
pub async fn run(mut self) {
while let Some(msg) = self.rx.recv().await {
match msg {
CacheMsg::Get { key, reply } => {
let _ = reply.send(self.inner.get(&key).cloned());
}
CacheMsg::Set { key, value } => {
self.inner.insert(key, value);
}
}
}
}
}
One owner, message passing for everything else. No locks. No Arc. If you’ve written Go, this should look familiar — it’s the same pattern, just enforced by the type system rather than by convention.
There are real cases where shared state is the right call: hot read paths, large data you can’t afford to copy through a channel, shared connection pools. For those, Arc<RwLock<T>> is fine. Just don’t make it your default.
Pattern 3: Lifetimes are usually inferred, name them when forced to
Most function signatures don’t need explicit lifetimes. The compiler’s elision rules cover the common cases. You start needing them when:
- A struct holds a reference (so it has a lifetime parameter).
- A function returns a reference derived from one of several inputs.
- You’re writing a trait that exposes references in its associated types.
// A struct that borrows — lifetime is required
pub struct Parser<'a> {
input: &'a str,
pos: usize,
}
impl<'a> Parser<'a> {
pub fn new(input: &'a str) -> Self {
Self { input, pos: 0 }
}
pub fn remaining(&self) -> &'a str {
&self.input[self.pos..]
}
}
The 'a here is saying: “this Parser doesn’t outlive the &str it was built from, and the slice I return lives at least as long as the input.” Once you read it that way, lifetime annotations stop being noise and start being documentation.
When to just clone
I’ll say the quiet part out loud: in application code that isn’t on a hot path, .clone() is fine. The borrow checker is not asking you to write zero-copy code. It’s asking you to be honest about who owns what. A .clone() is honest — it says “I’m taking my own copy, do what you want with yours.”
If you’re cloning a Uuid to put it in two places, that’s four bytes plus four bytes. If you’re cloning a 50 MB blob in a hot loop, that’s a performance problem worth fixing. Optimize the second case, not the first.
Pattern 4: The &mut self cascade
A subtle issue: once a method takes &mut self, every caller has to hold a &mut to your type. This propagates up. If your Database struct has &mut self methods, the whole call graph above it needs mutable access, which usually means a Mutex.
Interior mutability is the escape hatch. RefCell<T> for single-threaded, Mutex<T> or RwLock<T> for multi-threaded:
pub struct ConnectionPool {
inner: tokio::sync::Mutex<Vec<Connection>>,
}
impl ConnectionPool {
// &self, not &mut self — callers can share an Arc<ConnectionPool>
pub async fn get(&self) -> Connection {
let mut guard = self.inner.lock().await;
guard.pop().unwrap_or_else(Connection::new)
}
}
This is the pattern most production async services land on: services and pools are &self from the outside, with interior mutability where needed. Then Arc<Service> clones cheaply across tasks.
Common Pitfalls
The errors I see most often in code review from engineers in their first quarter of Rust:
- Returning a reference to a local. The compiler catches this, but the lesson is: if a function builds a value, return the value. Let the caller decide how to borrow from it.
Vec<&str>as a struct field. This forces a lifetime parameter on the struct and propagates pain upward. UseVec<String>and accept the allocation cost. Revisit only if profiling says so.- Re-borrowing in loops.
for item in &collectionis borrowing; you can’tcollection.push(...)inside. Either collect changes into a separate vec and apply after, or iterate by index. - Confusing
&Stringwith&str. They’re not the same.&Stringis a reference to a heap-allocated string;&stris a slice. APIs should almost always take&str. - Boxing too eagerly.
Box<dyn Trait>has its place but it costs a heap allocation and disables monomorphization. Generics are usually the right default; reach for dyn when you have a heterogeneous collection or want to break compile-time dependencies.
One more: the closure capture trap
Closures capture by the least restrictive mode they can. If your closure only reads, it captures &. If it mutates, &mut. If you move out, it captures by value. This bites people in async code:
let data = vec![1, 2, 3];
let task = tokio::spawn(async {
println!("{:?}", data); // ERROR: data isn't 'static
});
// Fix: move into the future
let data = vec![1, 2, 3];
let task = tokio::spawn(async move {
println!("{:?}", data);
});
Anything spawned onto tokio needs 'static, which in practice means captures must be by move. Reach for move in async {} blocks by default; the compiler will tell you if you didn’t need it.
Wrapping Up
The hardest part of learning Rust isn’t the syntax of lifetimes — it’s accepting that the compiler is asking real questions about your design and that “make it work like Python” isn’t a valid answer. Once you treat each borrow check error as feedback on your data model rather than as an obstacle, the language gets out of your way fast.
Next week I’ll write about how this connects to memory safety guarantees in practice — what the borrow checker actually protects you from, and what it doesn’t. After that, async Rust with tokio, which is where the rules get a second layer of complexity. If you want to read ahead, the Rust reference on borrowing is dry but precise.