The moment your variable disappears
You write a function to process a user's input string. You pass the string into the function. The function prints it. You try to use the string again on the very next line. The compiler rejects you with a hard error. The variable is gone. Not hidden, not shadowed, actually unusable. This is not a bug. This is Rust's ownership system doing exactly what it was designed to do.
Rust tracks who owns every piece of data. When you pass a value to a function, you are handing over that ownership. The function becomes the new owner. The original variable is immediately invalidated. If you still need the data after the function returns, you have to ask for it instead of giving it away. That asking is called borrowing.
Moving vs borrowing in plain English
Ownership in Rust works like a signed physical contract. Only one person can hold the original at a time. If you hand the contract to a lawyer, you no longer have it. The lawyer can read it, modify it, or shred it. You cannot claim you still hold the original. This is a move.
Borrowing works like showing a photocopy. You keep the original. The lawyer gets a temporary copy that expires when they finish their work. You can still use the original while they look at the copy. You can even let multiple people look at photocopies at the same time, as long as nobody tries to edit the original while copies are out. This is a borrow.
Rust enforces these rules at compile time. The compiler builds a mental map of every variable's lifetime. It checks every function call to ensure ownership rules are never violated. If the rules are broken, compilation stops. No runtime surprises. No dangling pointers. No use-after-free bugs.
The minimal example
/// Takes ownership of a String and prints it.
fn takes_ownership(s: String) {
// The parameter s now owns the heap data.
println!("{}", s);
}
/// Borrows a String and prints it without taking ownership.
fn borrows(s: &String) {
// The & means we only get read access.
println!("{}", s);
}
fn main() {
let s = String::from("hello");
// Passing s moves ownership into the function.
takes_ownership(s);
// This line would fail. s is gone.
// println!("{}", s);
// Create a fresh string to demonstrate borrowing.
let t = String::from("world");
// Passing &t creates a temporary reference.
borrows(&t);
// t is still fully usable because we only lent it.
println!("{}", t);
}
The compiler rejects any attempt to use s after takes_ownership(s) with E0382 (use of moved value). The error is immediate and unambiguous. You handed the data away. The compiler enforces that reality.
What happens under the hood
A String is not just a sequence of bytes. It is a small struct on the stack containing three pieces of metadata: a pointer to the heap, a length, and a capacity. The actual characters live on the heap.
When you pass a String by value, Rust copies those three stack values into the function's parameter slot. The heap data stays exactly where it is. The compiler then marks the original variable as invalid. If you tried to drop the original variable later, you would free heap memory that the function still needs. The move prevents that double-free scenario.
When you pass a reference (&String), Rust copies only the pointer and attaches a lifetime tag. The lifetime tag tells the compiler how long the reference is valid. The compiler checks that the reference never outlives the data it points to. The original variable keeps its ownership. The function gets temporary read access. When the function returns, the reference expires. The original variable remains untouched.
This design gives you zero-cost abstraction. References are just pointers with compile-time guarantees. There is no runtime overhead for checking permissions. The checks happen before the program ever runs.
Real-world function signatures
Production code rarely takes &String as a parameter. The community convention is to take &str instead. A &str is a string slice. It points to a sequence of UTF-8 bytes without caring whether those bytes live in a String, a &'static str, or a file buffer.
/// Processes a string slice instead of an owned String.
fn process_input(text: &str) {
// &str works with String, &str, and string literals.
println!("Processing: {}", text);
}
fn main() {
let owned = String::from("dynamic data");
let literal = "static data";
// Both coerce automatically to &str.
process_input(&owned);
process_input(literal);
}
Taking &str makes your function more flexible. It accepts any string-like data without forcing the caller to allocate a String. The compiler handles the coercion automatically. This is a small detail that pays off heavily in library design. Write functions that accept slices whenever you only need to read the data.
When the compiler draws the line
The borrow checker is strict about mutable access. You can have many immutable references, or exactly one mutable reference. Never both at the same time. This rule prevents data races and silent corruption.
/// Modifies a string in place.
fn append_greeting(name: &mut String) {
// &mut grants exclusive write access.
name.push_str(" (greeted)");
}
fn main() {
let mut user = String::from("Alice");
// This creates an immutable reference.
let read_handle = &user;
// This tries to create a mutable reference while read_handle is alive.
// append_greeting(&mut user);
// The compiler rejects this with E0502.
// It cannot borrow user as mutable because it is also borrowed as immutable.
println!("{}", read_handle);
// read_handle goes out of scope here.
// Now mutable borrowing is safe.
append_greeting(&mut user);
println!("{}", user);
}
The compiler tracks reference lifetimes precisely. It does not guess. If you try to mutate data while an immutable reference exists, compilation stops. The error message points to the exact line where the conflicting borrow begins. You fix the scope, not the logic.
Another common trap is trying to move data out of a reference. You cannot extract an owned value from a borrowed pointer. The compiler blocks this with E0507 (cannot move out of borrowed content). If you need to take ownership from inside a collection or a struct, you must use methods like replace, take, or std::mem::take. The compiler forces you to be explicit about ownership transfer.
Picking your parameter type
Use owned types when the function must keep the data after the caller is done. Use owned types when you are building a cache, a worker thread, or a long-lived struct field. Use owned types when the data is cheap to create and expensive to copy.
Use immutable references when the function only needs to read the data. Use immutable references when you want to accept multiple string types, slices, or collections without forcing allocation. Use immutable references when performance matters and you want to avoid heap copies.
Use mutable references when the function must modify the data in place. Use mutable references when you are implementing algorithms that sort, filter, or transform collections without allocating new ones. Use mutable references when you need exclusive access to guarantee thread safety or prevent concurrent reads.
Use Copy types when the data fits entirely on the stack and has no cleanup logic. Use Copy types for integers, floats, booleans, and small fixed-size arrays. Use Copy types when you want function calls to behave like mathematical operations with no ownership transfer.
Trust the borrow checker. It usually has a point.