TL;DR
Rust ownership explained — understand move semantics, borrowing rules, and how Rust guarantees memory safety without a garbage collector
Key concepts
- Rust ownership explained
- Rust move semantics
- Rust memory safety
- Rust borrow checker
Ownership & Borrowing
Ownership is Rust's most unique feature and has deep implications for the rest of the language. It enables Rust to make memory safety guarantees without needing a garbage collector.
What You'll Learn
By the end of this lesson you will write three helper functions that all work on one String living in main without ever taking it away — two that borrow it to read, one that borrows it to change in place — and the checks will fail if any of them quietly takes ownership instead. The part that catches people is not the moving; it is the aliasing: working out when two live references to the same value are allowed, and where a borrow actually ends. It does not end at the closing brace, and believing that it does will make the borrow checker look arbitrary for weeks.
In the capstone's taskwork, the tasks are read once into one collection and every view of them — the high-priority ones, the medium-priority ones, a count — is a borrowed slice of that collection rather than a copy of it, so nothing in the program clones the task list. That is the milestone this lesson feeds. You arrive able to write functions with parameter types and return types (Functions in Rust); what is new is that the parameter type now decides who owns the value afterwards.
What is Ownership?
Ownership is a set of rules that govern how a Rust program manages memory. Unlike languages with garbage collection (like Java or Python) or manual memory management (like C), Rust uses a third approach: memory is managed through a system of ownership with rules that the compiler checks at compile time.
The Three Rules of Ownership
- Each value in Rust has an owner — usually a variable, but a struct field or a collection element owns its value just as well
- There can only be one owner at a time
- When the owner goes out of scope, the value will be dropped
fn main() {
// s comes into scope
let s = String::from("hello");
// s is valid from this point forward
println!("{}", s);
} // s goes out of scope and is dropped
The Stack and the Heap
To understand ownership, we need to understand how data is stored:
- Stack: Fast, fixed-size data (integers, booleans, references)
- Heap: Dynamic-size data (Strings, Vectors, etc.)
fn main() {
// i32 is stored on the stack - copy is cheap
let x = 5;
// A copy, not a move, because i32 owns no heap buffer: duplicating the
// bits produces a second complete value with nothing to free twice.
let y = x;
println!("x = {}, y = {}", x, y); // Both are valid!
// String is stored on the heap
let s1 = String::from("hello");
// A move, because s1 owns a heap buffer that must be freed exactly once.
// Copying the pointer would give two owners and a double free, so Rust
// ends s1's ownership instead of duplicating the buffer.
let s2 = s1;
// println!("{}", s1); // ERROR: s1 is no longer valid
println!("{}", s2); // Only s2 is valid
}
Move vs Copy
Some types are duplicated on assignment instead of moved: the ones that implement the Copy trait. Copy is a trait a type opts into, not something the compiler infers from how big the value is — a type may implement it only if every one of its fields is itself Copy and the type has no Drop impl. That is why every integer, float, bool, char, and tuples of those are Copy, while String and Vec<T> are not — they own a heap buffer that has to be freed exactly once, and a second copy of the same pointer would free it twice. Size is a consequence, not the cause: String has a perfectly well-known compile-time size (24 bytes on a 64-bit target) and still moves, and &mut i32 is one pointer wide and still moves, while &i32 is the same width and copies. Compare the two assignments below:
fn main() {
// These types implement Copy
let a: i32 = 5;
let b = a; // copy
println!("a = {}, b = {}", a, b); // Both valid
// Strings do NOT implement Copy
let s1 = String::from("hello");
let s2 = s1; // move, not copy
// s1 is no longer valid here
}
Those two let b = a; lines look identical, and that is exactly the trap. Whether the original name survives the assignment depends entirely on the type on the right-hand side, not on how the line is written. Below, line D is commented out because leaving it in would stop the whole program from building. Work out why it is the one that had to go before you run this.
Predict
Lines A and B look like the same operation, but only one of them leaves the original variable usable — which is why line D had to be commented out while line C compiles. What is the difference, and what does this program print?
fn main() {
let count = 3;
let label = String::from("boxes");
let count_again = count; // line A
let label_again = label; // line B
// Exactly ONE of these two lines compiles. The other is commented out
// because leaving it in would stop the whole program from building.
println!("{} {}", count, count_again); // line C
// println!("{} {}", label, label_again); // line D
println!("{}", label_again);
}The program prints 3 3 and then boxes, and the line that had to be commented out is D, not B. i32 implements Copy, so line A duplicates the value and both count and count_again stay valid — line C prints 3 3. String owns a heap buffer, so it does not implement Copy: line B moves ownership into label_again, and from that point label is dead, though label_again prints boxes quite happily. Moving is always allowed; it is the later use that fails, which is why the error would land on line D. Keep that split in mind — the line the compiler blames is almost never the line that caused the problem.
When the Owner Goes Away
Rule three says the value is dropped when its owner goes out of scope. "Out of scope" covers more cases than the end of a function: a bare { } block is a scope, and handing a value to a function that takes it by value makes that function's parameter the new owner — so the value dies when the callee returns, not when main does. A type can observe this by implementing Drop.
struct Noisy {
name: String,
}
impl Drop for Noisy {
fn drop(&mut self) {
println!("dropping {}", self.name);
}
}
fn consume(item: Noisy) {
println!("consume got {}", item.name);
} // item is the owner now, so it is dropped HERE
fn main() {
let outer = Noisy { name: String::from("outer") };
println!("main owns {}", outer.name);
{
let inner = Noisy { name: String::from("inner") };
println!("block owns {}", inner.name);
} // inner goes out of scope here
let handed_off = Noisy { name: String::from("handed-off") };
consume(handed_off); // ownership moves into consume
println!("end of main");
} // outer is dropped last
Running that prints, in order: main owns outer, block owns inner, dropping inner, consume got handed-off, dropping handed-off, end of main, dropping outer. Three things to take from the trace. The inner block's value dies at the closing brace, long before main ends. The value handed to consume dies inside consume, because passing by value transfers ownership — main never gets it back. And outer, declared first, is dropped last, after end of main prints.
Borrowing with References
Instead of taking ownership, you can borrow a value using references:
fn main() {
let s1 = String::from("hello");
// &s1 lends a view rather than handing the value over, which is why the
// println below still has something to print.
let len = calculate_length(&s1);
// s1 is still valid because we only borrowed it!
println!("The length of '{}' is {}.", s1, len);
}
// The parameter is &String rather than String. Say why before reading on:
// calculate_length only needs to READ the value to measure it, so demanding
// ownership would destroy the caller's String to answer a question about it.
fn calculate_length(s: &String) -> usize {
s.len()
} // s goes out of scope, but it doesn't own the String
Forgetting that & is the single most common way a Rust beginner's program fails to compile. The program below hands a String to a helper by value and then tries to use it afterwards — the exact use-after-move the Predict above described, but this time you have to spot it in real code and read the compiler's own report. Commit a hypothesis about which line the compiler will blame before you touch anything.
Debug
This program prints a receipt label inside a box, then reports on it. It refuses to compile. The compiler names one variable and two different lines — work out which line actually caused the problem, then fix it so the program prints its box and then 'All checks passed.'
fn main() {
let receipt = String::from("order-4417");
// Hand the receipt to a helper that prints it in a box.
let width = print_boxed(receipt);
// Then report on the same receipt afterwards.
println!("printed {} in a box {} chars wide", receipt, width);
assert_eq!(width, 14, "the box should be the label plus 4 characters");
println!("All checks passed.");
}
fn print_boxed(label: String) -> usize {
let width = label.len() + 4;
println!("+{}+", "-".repeat(width - 2));
println!("| {} |", label);
println!("+{}+", "-".repeat(width - 2));
width
}Expected output: +------------+
| order-4417 |
+------------+
printed order-4417 in a box 14 chars wide
All checks passed.
The parameter type is the bug: print_boxed(label: String) takes the receipt by value, so calling it moves receipt into the function, and the String is dropped when print_boxed returns. By the time the println! runs, main has nothing left to print — hence error[E0382]: borrow of moved value: receipt. Note the compiler names two lines: the call is where the move happened, the println! is where it was noticed. The fix is one ampersand in each place: fn print_boxed(label: &String) and print_boxed(&receipt). The .clone() that rustc also suggests works too, but it copies the whole buffer to dodge a single character — borrow first, clone only when you truly need a second independent value.
Since ownership errors are the ones you will read most often for a while, it is worth naming the parts of the report rather than treating it as a wall of text. Every rustc diagnostic is assembled from labelled pieces, and each piece has a job. The error[E0382] code at the front is a stable identifier — wording changes between compiler releases, the code does not, so it is what you search for and what rustc --explain E0382 expands. The --> line gives the location as file, line and column. The ^^^ carets mark the span the compiler is objecting to, and the other underlined spans are the supporting evidence: in a move error one is labelled "value moved here" and another "value borrowed here after move", which is the compiler showing you the cause and the symptom as two separate places. A note: states a constraint it is reasoning from, and a help: offers a suggestion.
The habit worth building is reading help: as a suggestion rather than an instruction. It is optimised to make the error disappear, which is not always the same as making the program right — the .clone() above is the standard example, since it silences E0382 perfectly while copying an entire buffer to avoid typing one ampersand. Read the spans first to understand what happened, decide what the program should do, and only then look at whether the suggestion matches that decision.
Recall
Without scrolling up: back in Variables and Data Types you learned that Rust variables are immutable unless you write mut. Ownership is a different axis entirely. Given let s = String::from('hi'); — with no mut — which of these is true?
To say it plainly: mut and ownership are independent. mut decides whether you may change a value through a given name; ownership decides who is responsible for the value and when it is dropped. An immutable binding can still be moved away, and once it is, the problem is not immutability but that the name no longer owns anything. The next section combines both ideas — a mutable reference lends a value and permits changing it.
Mutable References
By default, references are immutable. To modify borrowed data, use mutable references:
fn main() {
let mut s = String::from("hello");
change(&mut s);
println!("{}", s); // Prints "hello, world"
}
fn change(s: &mut String) {
s.push_str(", world");
}
This one carries no notes on purpose. Three choices in it are worth stating yourself before reading on: why s is declared mut in main, why the parameter is &mut String rather than &String, and why the call site has to write &mut s rather than just s. Taking them in order — push_str changes the value, so the owner must permit mutation; a shared reference only grants reading, so modifying through one is rejected; and the &mut at the call site is Rust making the lend explicit at both ends, so a reader of main can see that change may alter s without having to go and read change.
The Rules of References
- You can have either one mutable reference or any number of immutable references
- References must always be valid (no dangling references)
Those two rules are memorable but incomplete, and the missing piece is the one that decides real programs: a borrow lives from where it is created until its LAST USE, not until the closing brace of its scope. Rule one is about borrows that are live at the same moment, so knowing where a borrow ends is what makes it usable at all. A reference that is still in scope but will never be read again is already dead as far as the compiler is concerned, and a new mutable borrow may be taken over the top of it.
That has a consequence worth bracing for: whether a line compiles can depend on code written below it. Adding one more read of an old reference at the bottom of a function extends that reference's life backwards over everything in between, and a line in the middle that compiled a moment ago stops compiling — with nothing on that line having changed. Borrowing and References returns to this and names the error you get.
fn main() {
let mut s = String::from("hello");
// Multiple immutable references are OK
let r1 = &s;
let r2 = &s;
println!("{} and {}", r1, r2);
// After r1 and r2 are no longer used, we can have a mutable reference
let r3 = &mut s;
r3.push_str(" world");
println!("{}", r3);
}
That program compiles, and the reason it does is the whole point of this section. Both r1 and r2 are still in scope when r3 is created — the closing brace is well below — yet the mutable borrow is allowed. Before reading on, work out exactly where the two shared borrows end, and what would have to change for the same program to be rejected.
Predict
The program above compiles even though r1 and r2 are still in scope when the mutable borrow r3 is taken. Two questions, and the second one is the one that matters: does it still compile if one extra line, println with r1, is added at the very bottom — and where do the shared borrows actually end in each version?
fn main() {
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{} and {}", r1, r2); // last line that reads r1 and r2
let r3 = &mut s;
r3.push_str(" world");
println!("{}", r3);
// println!("{}", r1); // <-- what if this line is uncommented?
}Preventing Data Races
The same rule applied to two mutable borrows is what rules out data races at compile time. The fence below has a second mutable borrow sitting commented out. Decide for yourself what happens if it is uncommented, and — using the last-use rule from the section above — whether moving the final println! would change your answer.
fn main() {
let mut s = String::from("hello");
let r1 = &mut s;
// let r2 = &mut s;
println!("{}", r1);
}
Uncommenting that line gives error[E0499]: cannot borrow s as mutable more than once at a time, and the report names three places: where the first mutable borrow occurs, where the second one does, and where the first borrow is later used. That third span is the one doing the work, exactly as in the shared-borrow case: it is the println! at the bottom that keeps r1 alive across the second borrow. Delete that println! and r1 is dead the moment after it is created, so the second borrow no longer overlaps anything and the program compiles. Two mutable references to one value are not forbidden by spelling — they are forbidden from being live at the same time, and what you do with them afterwards decides whether they are.
The Slice Type
Slices let you reference a contiguous sequence of elements rather than the whole collection:
fn main() {
let s = String::from("hello world");
let hello = &s[0..5]; // or &s[..5]
let world = &s[6..11]; // or &s[6..]
println!("{} {}", hello, world);
}
Practice Exercise
Reading about borrowing is not the same as choosing the right signature under your own name. This is a build task: a small program that reports its own pass/fail. One String lives in main and never leaves it; three helper functions have to work on it without taking it away. Run the starter as-is and it fails immediately, naming the first check that did not pass. Implement each function until every check passes and it prints All checks passed.
The three signatures are already written for you, and they are the lesson in miniature: two take &String (lend, read only) and one takes &mut String (lend, and allow changes). Watch the second check in particular — it asserts that main still owns its String after the first call, which is the property a by-value parameter would silently destroy.
Build
Finish the build. Three functions are stubbed out and the checks below them fail until each one behaves. Run it as-is to see which check fails first, decide what that function is missing, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down. Do not change any of the three signatures: the whole point is that none of them takes ownership.
// TODO 1: count the words in text WITHOUT taking ownership of it.
// The caller must still own its String afterwards, so this borrows.
// word_count(&String::from("a b c")) -> 3
// Hint: split_whitespace() gives an iterator; count() consumes it.
fn word_count(text: &String) -> usize {
let _ = text;
0
}
// TODO 2: return a NEW uppercased String, leaving text untouched.
// shout(&String::from("hi")) -> "HI", and the caller's "hi" is unchanged.
fn shout(text: &String) -> String {
let _ = text;
String::new()
}
// TODO 3: append note to text IN PLACE through the mutable borrow.
// append_note(&mut s, "!") leaves the caller's s one character longer.
fn append_note(text: &mut String, note: &str) {
let _ = text;
let _ = note;
}
// --- Build checks: these must all pass. Do not edit below this line. ---
fn main() {
let mut report = String::from("all systems nominal");
assert_eq!(word_count(&report), 3, "word_count should see 3 words");
assert_eq!(report.len(), 19, "word_count must BORROW: report is still owned by main");
let loud = shout(&report);
assert_eq!(loud, "ALL SYSTEMS NOMINAL", "shout should return an uppercased copy");
assert_eq!(report, "all systems nominal", "shout must not disturb the original");
append_note(&mut report, " (checked)");
assert_eq!(report, "all systems nominal (checked)", "append_note should extend report in place");
assert_eq!(word_count(&report), 4, "the appended note adds a fourth word");
println!("All checks passed.");
println!("Report: {}", report);
println!("Shouted: {}", loud);
println!("Words: {}", word_count(&report));
}Expected output: All checks passed.
Report: all systems nominal (checked)
Shouted: ALL SYSTEMS NOMINAL
Words: 4
Once it passes, try two variations and predict each before running:
- Take ownership instead of borrowing. Change
word_count's signature tofn word_count(text: String)and drop the&from all three of its call sites. Predict which check breaks before running. It does not fail a check at all — it never gets that far. Compilation fails witherror[E0382]: borrow of moved value: report, pointing at the very next line:report.len()reads aStringthat was moved intoword_countand dropped there. (Change only the first call site and you geterror[E0308]: mismatched typesat the other two instead — a signature and its callers have to agree before ownership is even considered.) Exactly the bug from the debug block above, reintroduced by a missing ampersand. - Drop the
muton the borrow. Changeappend_note's parameter totext: &String(leavingpush_strin the body). Predict what the compiler says before running. You geterror[E0596]: cannot borrow *text as mutable, as it is behind a & reference—push_strrequires a mutable borrow, so the signature and the operation have to agree. This is the compiler enforcing the read-only promise the&made.
Assembling a Move and a Borrow
Arrange the code
These five lines move a String into a second name, take a shared reference to it, measure it through that reference, and print both. They have been shuffled. Put them in the order that compiles — then answer the ownership question the order encodes: after the move, which name may the reference be taken from, and why can the reference not be created before the move happens?
let original = String::from("order-4417");let view = &owned;let owned = original;println!("{} is {} wide", view, width);let width = view.len() + 4;
Carrying Ownership Somewhere New
Transfer
Rust drops a value when its owner goes out of scope, and the compiler decides where that happens while it is compiling. A garbage-collected language such as Java or Python instead frees a value at some point after the last reference to it disappears, decided while the program is running. Both systems free memory without the programmer calling free. Which statement names what genuinely transfers between them, rather than a surface resemblance?
Capstone milestone
The capstone's taskwork reads its tasks once into a single collection and then prints several different views of them — the ones at a chosen priority, a count of those, a count of the whole list — without ever copying the task list. That is this lesson's skill at program scale: ownership settles in one place, and every function that needs the data borrows it. Take the three-signature shape you just built and write the function taskwork leans on hardest — one that borrows the whole collection and hands back a selected subset of borrowed items — and then, to prove you own the other half of the rule, one that borrows the collection mutably and changes a single item in place. Only the first is in the capstone; the second is here because a borrow you may write through is the case the selection function has to stay clear of.
Hint: Start from the three signatures in the build task above: two shared borrows and one mutable borrow. The only thing that changes at capstone scale is that the borrowed thing is a collection rather than a String, and that the selection function returns references INTO it rather than a number.
- A selection function that takes a shared reference to the collection and returns borrowed items rather than owned copies, with the caller still able to use the collection afterwards.
- An update function that takes a mutable reference and changes one item in place, rather than returning a modified copy for the caller to assign back.
- No clone anywhere in either function, and no signature that takes the collection by value.
- A main that calls the selection function twice on the same collection, proving the first call did not consume it.
Key Takeaways
- Ownership ensures memory safety without a garbage collector
- Each value has exactly one owner
- When the owner goes out of scope, the value is dropped
- References allow borrowing without taking ownership
- You can have one mutable reference OR multiple immutable references
- The borrow checker enforces these rules at compile time
Understanding ownership is fundamental to writing safe, efficient Rust code!
Next Steps
Now that you understand ownership, you're ready to learn about structs and enums — Rust's tools for building custom data types that work hand in hand with the ownership system. You'll see how they let you model your domain precisely while the borrow checker keeps the data safe.
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.