Rust Ownership | Ownership, Borrowing, and Lifetimes
Key takeaways
Deep dive into Rust ownership: move and copy, functions and ownership, references, slices, lifetime annotations, and patterns compared to C++—with runnable examples.
Introduction
Ownership is Rust’s signature feature: memory safety without a garbage collector by enforcing rules at compile time. Think of each heap value as a single key to an apartment—only one variable holds the key; when it goes out of scope, Rust locks the door (drops) exactly once. There is no duplicate key that could double-free. Compare with C++ smart pointers and Python’s GC-backed objects.
Most languages pick one of two approaches to memory. C and C++ let the programmer free memory manually (or through destructors), which is fast but makes use-after-free, double free and leaks possible. Java, Go, Python and JavaScript use a garbage collector, which removes those bugs but adds a runtime, pauses or overhead, and less predictable cleanup of non-memory resources. Rust takes a third route: the compiler tracks who owns each value and inserts the cleanup itself, so there is no GC at run time and no manual free in the source. The price is paid at compile time, in the form of rules that reject some programs a C++ compiler would accept. Most of the frustration newcomers feel with Rust comes from those rejections, and most of them make sense once you see which bug each rule prevents. This article walks through the rules in the order you hit them: moves, function calls, borrowing, slices and lifetimes.
Ownership rules
Rule 1: Every value has an owner
fn main() {
let s = String::from("hello");
// `s` owns the heap-allocated string
}
What this means: After String::from allocates on the heap, s is the sole owner of that memory. When s leaves scope, Rust calls drop—no manual free like in C.
It helps to picture what s actually is. The variable itself lives on the stack and is three machine words: a pointer to the heap buffer, the length (5) and the capacity. The bytes h e l l o live on the heap. “Ownership” means that this particular stack value is responsible for freeing that particular heap buffer. Types that own nothing on the heap, such as i32 or (f64, f64), still have owners, but dropping them is a no-op.
Rule 2: Only one owner
Each value has exactly one owner at a time:
fn main() {
let s1 = String::from("hello");
// Move: ownership transfers fully to s2
// s1 is no longer valid
let s2 = s1;
// println!("{}", s1); // Compile error!
// "value borrowed here after move"
println!("{}", s2); // OK — s2 owns the data
}
// When s2 goes out of scope, the string is dropped once
The actual compiler output for the commented line is error[E0382]: borrow of moved value: 's1', with a note pointing at let s2 = s1; saying “value moved here” and a hint that String does not implement the Copy trait. Reading that note first is the fastest way to fix these errors: it tells you exactly where ownership left.
Why move? C++ pitfall:
// std::string itself is safe: copy is a deep copy, move leaves s1 valid-but-unspecified.
// The classic double free comes from a hand-written owning class:
struct Buffer {
char* data;
~Buffer() { delete[] data; }
// no copy constructor defined -> the compiler-generated one copies the pointer
};
Buffer a{new char[16]};
Buffer b = a; // b.data == a.data
// both destructors run delete[] on the same pointer -> double free
Perspective: In C++, std::string does the right thing, but any class that owns a raw resource needs its copy, move and destructor written consistently (the “rule of three/five”), and the compiler will happily generate a shallow copy if you forget. Rust removes that category of bug by making assignment a move for any type that is not explicitly Copy, and by making deep copies explicit with clone().
A move in Rust is a bitwise copy of the stack part (pointer, length, capacity) followed by the compiler marking the source as unusable. No heap data is touched, so it is cheap. The key difference from C++‘s std::move is that the moved-from variable is not in some “valid but unspecified” state you can still call methods on; the compiler simply refuses to let you use it, and no destructor runs for it.
Rust’s approach:
- Deep copies are costly (allocation + memcpy)
- By default, ownership moves (transfer + invalidate source)
- Double free is impossible (single owner)
- Need a duplicate? Call
clone()explicitly
let s1 = String::from("hello");
let s2 = s1.clone(); // Deep copy (explicit)
println!("{}, {}", s1, s2); // Both OK
clone in production: It can be CPU- and memory-heavy—profile hot loops and avoid redundant clones. Sometimes you choose clone() anyway to simplify APIs when correctness matters more than micro-optimization.
The Copy trait:
// Small stack types (integers, bool, etc.) implement Copy
// Assignment copies instead of moving
let x = 5;
let y = x; // Copy, not move
println!("{}, {}", x, y); // Both OK
// Cheap to copy: fixed small size on the stack
Copy is not about size, it is about whether a bitwise copy is a complete, safe duplicate. Integers, floats, bool, char, shared references &T, and tuples or arrays made only of Copy types all qualify. String, Vec<T> and Box<T> do not, because a bitwise copy would produce two owners of one heap buffer. You can opt your own types in with #[derive(Clone, Copy)], but only if every field is Copy, and a type that implements Drop can never be Copy. A useful rule is to derive Copy for small plain-data structs such as Point { x: f32, y: f32 }, and not for anything you might later want to add a heap field to, because removing Copy from a public type breaks every caller that relied on implicit copies.
Rule 3: Drop at end of scope
fn main() {
{
let s = String::from("hello");
println!("{}", s);
} // `drop` runs for `s` here
// println!("{}", s); // Error: s is gone
}
RAII: Use the same pattern for resources (files, locks, connections) scoped to a block—cleanup runs automatically. This ties into the Drop trait and idiomatic resource management in Rust.
This is the same idea as C++ RAII, with one guarantee C++ cannot give: because each value has a single owner and moved-from values are not dropped, drop runs exactly once. A MutexGuard unlocks when it goes out of scope, a File closes, a TcpStream shuts down. Values in one scope are dropped in reverse order of declaration. If you need to release something before the end of the scope, such as a lock you are holding while doing slow I/O, call drop(guard); explicitly or put the work in its own inner block. Holding a MutexGuard longer than intended is one of the more common sources of deadlocks and contention in Rust code, precisely because the release point is implicit.
Functions and ownership
Moving into a function
fn main() {
let s = String::from("hello");
takes_ownership(s);
// println!("{}", s); // Error: ownership moved
}
fn takes_ownership(s: String) {
println!("{}", s);
} // `s` is dropped here
Passing by value: Giving a String to a function consumes it for the caller. Use .clone() or references (&String / &str) if the caller must keep using it. Many codebases distinguish consuming APIs from borrowing APIs by naming and signatures.
Returning ownership
fn main() {
let s1 = gives_ownership();
let s2 = String::from("hello");
let s3 = takes_and_gives_back(s2);
println!("{}, {}", s1, s3);
}
fn gives_ownership() -> String {
String::from("hello")
}
fn takes_and_gives_back(s: String) -> String {
s
}
Returning a value moves ownership back out to the caller, so gives_ownership can create a String and hand it over without any copy. takes_and_gives_back works, but it shows why the style does not scale: if every function that only wanted to look at a string had to take it and return it, you would be threading values in and out of every call, and returning extra results would require tuples like (String, usize). References, in the next section, exist to remove that ceremony.
When should a function take ownership? When it genuinely needs to keep or consume the value: storing it in a struct, sending it to another thread, or transforming it into something else (fn into_bytes(self) -> Vec<u8>). Constructors are the typical case, User::new(name: String), because the struct will own the name. If the function only reads, take &str or &[T]; if it modifies in place, take &mut. Choosing the parameter type is really choosing what the caller has to give up.
References and borrowing
Immutable references (&)
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1);
println!("{} length: {}", s1, len); // s1 still usable
}
fn calculate_length(s: &String) -> usize {
s.len()
} // s is a reference; nothing is dropped here
Mutable references (&mut)
fn main() {
let mut s = String::from("hello");
change(&mut s);
println!("{}", s); // hello, world
}
fn change(s: &mut String) {
s.push_str(", world");
}
A reference is a pointer that the compiler proves is valid for as long as it is used. &s1 borrows s1 for the duration of the call; the caller keeps ownership, and nothing is freed when the reference goes away. &mut has to be written at both ends: the variable must be declared let mut s, and the call site must say &mut s. That redundancy is deliberate. Reading change(&mut s) tells you at the call site that s may be modified, which C++ references (void change(std::string& s) called as change(s)) do not show.
calculate_length takes &String, which is legal but not idiomatic. Clippy flags it (ptr_arg) and suggests &str, because &String automatically coerces to &str while the reverse is not true. A &str parameter accepts string literals, slices of other strings and Strings alike; a &String parameter forces callers to have an owned String. The same applies to &Vec<T> versus &[T].
The borrowing rules
Rust’s rules prevent data races at compile time:
fn main() {
let mut s = String::from("hello");
// Multiple immutable borrows are allowed
let r1 = &s;
let r2 = &s;
println!("{}, {}", r1, r2); // OK
// Only one mutable borrow at a time
let r3 = &mut s;
// let r4 = &mut s; // Error!
println!("{}", r3); // OK
// Cannot mix & and &mut in the same live region
let r5 = &s;
// let r6 = &mut s; // Error!
println!("{}", r5);
}
The rule is often summarized as “many readers or one writer, never both”. It is the same rule a read-write lock enforces at run time, applied statically. Why is that worth the friction? Because most memory bugs are really aliasing bugs: one piece of code holds a pointer into a Vec while another pushes to it and reallocates, or one thread reads while another writes. If a mutable reference is guaranteed to be the only way to reach the data while it exists, those situations cannot be written.
Notice that r3 = &mut s compiles even though r1 and r2 were created earlier. Since the 2018 edition, borrows last only until their last use (non-lexical lifetimes), not until the end of the block. r1 and r2 are last used in the first println!, so by the time r3 is created there are no live shared borrows. The same reasoning is why r5 can be created after r3’s last use. When you do hit the error, it reads error[E0502]: cannot borrow 's' as mutable because it is also borrowed as immutable, with labels showing where the immutable borrow starts and where it is used later. The fix is almost always to move that later use earlier or to end the first borrow sooner, not to add clone().
Slices
A slice references a contiguous portion of a collection:
fn main() {
let s = String::from("hello world");
let hello = &s[0..5]; // "hello" as &str
let world = &s[6..11]; // "world"
println!("{}, {}", hello, world);
let hello2 = &s[..5];
let world2 = &s[6..];
let full = &s[..];
}
// First word: slice example
fn first_word(s: &String) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
fn main() {
let sentence = String::from("hello world");
let word = first_word(&sentence);
println!("first word: {}", word);
// If `word` borrows `sentence`, mutating `sentence` can fail to compile:
// let mut s = String::from("hello world");
// let word = first_word(&s);
// s.clear(); // Error while `word` is live
}
(The block shows two fn main functions for illustration; in a real file, keep one.)
A &str slice is a pointer into the original string plus a length, with no allocation of its own. That is why first_word can return &s[0..i] cheaply, and also why the commented-out s.clear() is rejected: clearing needs &mut s, and word is still an active shared borrow of the same data. In C++, the equivalent code with std::string_view compiles and leaves word dangling. This is one of the clearest examples of the borrow checker catching a real bug, because the alternative design, returning the index of the first space as a usize, compiles in both languages and silently goes stale when the string changes.
One trap that surprises people coming from other languages: string slice ranges are in bytes, not characters. &s[0..5] works for "hello", but for "안녕하세요" each character is three bytes in UTF-8, and &s[0..1] panics at run time with byte index 1 is not a char boundary. When the text may not be ASCII, slice at indices you obtained from char_indices(), find() or split_whitespace() rather than at hard-coded numbers.
Slice types:
let s: String = String::from("hello");
let slice: &str = &s[0..2]; // "he"
let arr = [1, 2, 3, 4, 5];
let slice: &[i32] = &arr[1..3]; // [2, 3]
// Slice = pointer + length; out-of-range access panics
Because a slice carries its own length, every index is bounds-checked, and going past the end panics with range end index 10 out of range for slice of length 5 instead of reading neighbouring memory. The optimizer removes many of those checks when it can prove they cannot fail, for example inside a for x in slice loop. Functions that take &[T] work with arrays, Vec<T> and sub-slices alike, which is why slices, not &Vec<T>, are the idiomatic way to accept “a sequence of T” in Rust APIs.
Lifetimes
Lifetime annotations
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
fn main() {
let s1 = String::from("long string");
let s2 = String::from("short");
let result = longest(&s1, &s2);
println!("longest: {}", result);
}
A lifetime annotation does not change how long anything lives. It is a constraint the function signature promises. longest<'a>(x: &'a str, y: &'a str) -> &'a str says “the returned reference is valid for as long as both inputs are valid”. The compiler needs that promise because, looking only at the signature (which is all a caller sees), it cannot tell whether the result points into x or y. Without the annotation, you get error[E0106]: missing lifetime specifier with the hint “this function’s return type contains a borrowed value, but the signature does not say whether it is borrowed from x or y”.
The annotation then shapes what callers may do. If s2 were created in an inner block and result used after that block ends, the call is rejected with “s2 does not live long enough”, because 'a must be no longer than the shorter of the two inputs. Most functions never need explicit lifetimes thanks to elision rules: a function with one reference parameter gets its output tied to that parameter automatically, and a method with &self ties outputs to self. You only write 'a when there are several reference inputs and the compiler cannot guess which one the output borrows from.
Struct lifetimes
struct ImportantExcerpt<'a> {
part: &'a str,
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().unwrap();
let excerpt = ImportantExcerpt {
part: first_sentence,
};
println!("{}", excerpt.part);
}
A struct that holds a reference needs a lifetime parameter, and ImportantExcerpt<'a> reads as “an excerpt cannot outlive the text it points into”. Here novel is declared before excerpt and lives until the end of main, so everything checks out. Move novel into an inner block and try to print excerpt.part after the block, and the compiler reports that novel does not live long enough.
In my experience, reference-holding structs are where Rust newcomers lose the most time. The lifetime parameter spreads to every function and struct that stores or returns the excerpt, and a design that seemed natural (a parser struct that keeps &str slices of its input, a cache that stores references into a vector it also mutates) ends in a long fight with the borrow checker. The pragmatic default is to make structs own their data (part: String) and accept the allocation, and to reach for borrowed fields only for short-lived views where the performance matters and the lifetimes are simple. If you need shared ownership, Rc<str> or Arc<str> are the idiomatic middle ground.
Hands-on example
String processing
fn main() {
let text = String::from("hello rust world");
let words = split_words(&text);
println!("words: {:?}", words);
let first = first_word(&text);
println!("first word: {}", first);
}
fn split_words(s: &String) -> Vec<&str> {
s.split_whitespace().collect()
}
fn first_word(s: &String) -> &str {
s.split_whitespace().next().unwrap_or("")
}
split_words returns Vec<&str>: a new vector (owned) whose elements are slices borrowing from text (not owned). No lifetime annotation is needed because elision ties the output to the single input. The consequence is that words cannot outlive text, and text cannot be mutated while words is in use. If you need the words to survive independently, collect owned strings instead with .map(String::from).collect::<Vec<String>>(), paying one allocation per word.
Both helpers would be better written with s: &str parameters, which lets them accept "literal" directly. unwrap_or("") in first_word is also a design decision: it maps “no words” to an empty string. Returning Option<&str> would let the caller distinguish an empty input from a real first word, which is usually the more Rust-like choice.
Compared to other languages
| Concern | C++ | Java / Python / Go | Rust |
|---|---|---|---|
| Who frees heap memory | Destructors, smart pointers, or manual delete | Garbage collector | Compiler-inserted drop at the owner’s scope end |
a = b for a heap-owning type | Deep copy (copy constructor) unless std::move | Copies the reference; both names share the object | Move; b becomes unusable. clone() for a copy |
| Use after free / dangling reference | Possible, undetected by the compiler | Not possible (GC keeps objects alive) | Rejected at compile time in safe code |
| Data race on shared mutable data | Possible | Possible (Java, Go) | Rejected at compile time in safe code |
| Run-time cost of the model | None beyond what you write | GC work and pauses | None; the checks happen at compile time |
| Cost to the programmer | Discipline, rule of three/five | Little | Learning the borrow rules, restructuring some designs |
The honest trade-off is in the last row. Rust does not make memory management free; it moves the cost from debugging time to compile time. Graph-like structures with many mutual references, callbacks that capture state, and long-lived caches of references are harder to express than in C++ or a GC language, and usually end up using indices, Rc<RefCell<T>> or arenas. In return, whole classes of bugs that would otherwise show up as crashes or corrupted data in production are compile errors instead. For how this compares in depth to C++‘s model, see Rust memory safety vs C++.
Related Articles
- Rust memory safety deep dive | Borrow checker and lifetimes
- C++ and Rust interoperability [#44-2]
- Getting Started with Rust | Memory-Safe Systems Programming
- C++ shared_ptr vs unique_ptr: Smart Pointer Choice Complete
- Python Data Types | Lists· Dictionaries
Frequently Asked Questions (FAQ)
Q. Why can I still use an i32 after assigning it, but not a String?
A. Types like i32, bool, and char implement Copy, so assignment duplicates the bits and both variables stay valid. String owns a heap buffer and is not Copy, so assignment moves ownership and the original variable can no longer be used, which prevents two owners from freeing the same memory. If you really need two independent strings, call .clone() explicitly, or pass a reference &s when the callee only needs to read it.