Rust Ownership and Borrowing for People Who Already Know Pointers
TL;DR — Every value has exactly one owner. Assignment moves ownership. Borrowing (
&T,&mut T) lends temporary access. You can have many&Tor one&mut T, never both. Three rules; everything else follows.
After the “why Rust” post, the first conceptual hurdle is ownership. It’s the thing Rust has that no other mainstream backend language has, and it’s why Rust feels so different from Go even when the syntax looks similar.
I’ll skip the metaphors. If you’ve written Go (or C), you already understand pointers. Ownership is a set of rules layered on top of pointer semantics. The rules are simple. The implications take a while to sink in.
Three rules
These three rules cover 95% of what you need to know.
Rule 1: Every value has exactly one owner. When that owner goes out of scope, the value is dropped (memory freed, file closed, lock released, whatever).
Rule 2: Assignment moves ownership, by default. If you do let b = a;, ownership of the value transferred from a to b. a is now invalid; you cannot use it.
Rule 3: You can borrow instead. &value is an immutable reference (the value is loaned, not moved). &mut value is a mutable reference. At any time you can have many & or exactly one &mut. Never both.
That’s it. The borrow checker enforces these at compile time. If it can’t prove your code follows them, it refuses to compile.
The move that surprises Go developers
In Go:
a := []int{1, 2, 3}
b := a // both point at the same underlying array
fmt.Println(a, b) // works fine
In Rust:
let a = vec![1, 2, 3];
let b = a;
println!("{:?} {:?}", a, b);
// error[E0382]: borrow of moved value: `a`
That’s the move. let b = a; transferred ownership of the Vec to b. a is now invalid. If you try to use it, compile error.
Two ways out:
Borrow instead of move:
let a = vec![1, 2, 3];
let b = &a;
println!("{:?} {:?}", a, b);
// works — `b` is a borrowed reference; `a` still owns the Vec
Clone (allocate a copy):
let a = vec![1, 2, 3];
let b = a.clone();
println!("{:?} {:?}", a, b);
// works — `b` owns its own Vec
In Go the equivalent of “borrow” is “pass the slice header by value” (cheap), and the equivalent of “clone” is copy(b, a). Both exist; Rust just makes you explicit about which you’re doing.
Why moves exist: no double-free
Why is Rust this fussy? Because the alternative — both a and b “owning” the Vec — leads to double-free bugs in any language without a GC. C has them. C++ tries to solve it with copy/move constructors and gets cited as the source of half the world’s CVEs. Go solves it by garbage-collecting everything. Rust solves it by saying “only one owner” and letting the compiler check.
The cost is that you have to think about ownership transfer. The benefit is no GC, no double-frees, and no use-after-free, all enforced at compile time.
Copy types — the small exception
Types that are cheap to copy bit-for-bit (i32, bool, char, fixed arrays of Copy types) implement the Copy trait. For these, let b = a; copies instead of moving:
let a: i32 = 42;
let b = a;
println!("{} {}", a, b); // both valid — i32 is Copy
The mental model: small primitives copy, heap-allocated things (Vec, String, Box) move. You’ll internalise the distinction quickly.
Borrowing: & and &mut
Two flavours of reference:
let mut s = String::from("hello");
let r1 = &s; // immutable borrow
let r2 = &s; // another immutable borrow — fine
println!("{} {}", r1, r2);
let r3 = &mut s; // mutable borrow — also fine, because r1/r2 are no longer used
r3.push_str(" world");
println!("{}", r3);
Two key rules to internalise:
Many &, or one &mut. Never both at the same time.
let mut s = String::from("hello");
let r1 = &s;
let r2 = &mut s; // error: can't borrow mutably while immutable borrow exists
println!("{} {}", r1, r2);
&mut is exclusive.
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s; // error: can't borrow mutably twice
println!("{} {}", r1, r2);
The reason for the exclusivity: a &mut reference can change the value, and the borrow checker won’t allow any other reference (even read-only) to coexist with that, because the reader might see partially-mutated data.
This is the part that translates directly to “data races impossible at compile time.” In Go, two goroutines holding pointers to the same map can corrupt it. In Rust, you literally cannot construct that situation in safe code — the compiler refuses.
Functions and ownership
Function signatures encode ownership intent:
// Takes ownership. Caller's value is moved in and dropped at function end.
fn consumes(s: String) {
println!("consumed: {}", s);
}
// Borrows immutably. Caller still owns it.
fn reads(s: &str) {
println!("read: {}", s);
}
// Borrows mutably. Caller still owns it but can't read during the call.
fn modifies(s: &mut String) {
s.push_str("!");
}
fn main() {
let mut s = String::from("hello");
reads(&s); // borrow — `s` still usable after
modifies(&mut s); // mutable borrow — `s` still usable after
consumes(s); // moves — `s` no longer usable
// println!("{}", s); // compile error
}
The signature is the contract. &str says “I just need to look at this string.” &mut String says “I need to modify it.” String says “I’m taking ownership.”
When you read library code, the function signatures alone tell you what’s borrowed, what’s moved, and what the caller’s responsibilities are. It’s much more information-dense than Go.
Borrowing makes errors local
A subtle benefit: borrow-checker errors are local. The compiler tells you exactly which two borrows conflict, on which lines. You don’t have to step through code to find the data race — the compiler points at it.
Example error:
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
--> src/main.rs:5:5
|
3 | let first = &v[0];
| - immutable borrow occurs here
4 |
5 | v.push(6);
| ^^^^^^^^^ mutable borrow occurs here
6 |
7 | println!("first: {}", first);
| ----- immutable borrow later used here
This says: “you took a reference to v[0] on line 3. On line 5 you tried to mutate v, which could invalidate that reference (if push triggers a reallocation, the pointer to v[0] is dangling). Don’t.”
In Go, you’d discover this at runtime via a slice corruption bug six months later.
The patterns that make it click
Three patterns I lean on heavily as a beginner:
Pattern 1: Pass borrows, return owned. Functions take &T parameters and return T. Caller keeps ownership of inputs, becomes owner of outputs. Reads natural; lets the borrow checker stay out of your way.
fn upper(s: &str) -> String {
s.to_uppercase()
}
Pattern 2: Clone when you can’t borrow. If lifetime gymnastics are getting in the way, just .clone(). It allocates. It’s also fine for most code. Save the borrow gymnastics for hot paths.
Pattern 3: Reach for Arc/Rc when you need shared ownership. When ownership genuinely needs to be shared (e.g., between multiple async tasks), wrap in Arc<T> (atomic reference counted). Then you can .clone() cheaply — both clones point at the same underlying data; it’s freed when the last Arc drops.
use std::sync::Arc;
let config = Arc::new(load_config());
let c1 = Arc::clone(&config);
let c2 = Arc::clone(&config);
// pass c1, c2 into separate tasks; original `config` still valid
Common Pitfalls
Fighting the borrow checker on hot paths first. Beginners try to write the most performance-critical code first. Don’t. Write the easy stuff with .clone() and Arc. Once you have a working program, profile, then optimize the borrows where it matters.
Confusing &str and String. String is owned and heap-allocated. &str is a borrowed view (substring of a String, or a string literal). Functions should usually take &str (more flexible). Functions that need to store strings should return String.
Returning references to local variables. The Go habit: “just return a pointer.” In Rust you can’t return a reference to a local — the local would be freed. Either return owned, or take a lifetime parameter (next post).
Trying to mutate while iterating. Iterating creates an immutable borrow. Mutating during iteration violates the borrow rules. Use .iter_mut() if you need mutation, or collect indices first.
Thinking .clone() is always bad. In a backend service serving thousands of requests, a few .clone()s on a request-local config are invisible. Optimize when it matters; don’t preemptively complicate code.
Reading “moved value” errors as failures. They’re guidance. The compiler is preventing a class of bug. Either restructure to borrow, or accept the move and pass ownership.
Wrapping Up
Three rules: one owner, moves on assignment, borrow instead. Two reference flavours: many & or one &mut. Everything else falls out from those. The first week feels like fighting the compiler; by week three it starts catching real bugs you’d otherwise ship. Monday: lifetimes — the part of Rust that scares everyone, explained without the fear.