Rust’s biggest promise is memory safety without garbage collection. The compiler enforces memory rules at compile time through a system of ownership and borrowing. This guide explains those rules — not just the syntax, but the mental models that make them click.
Why Rust Feels Different
In C and C++, the programmer manually manages memory — allocate, use, free. Forget to free: memory leak. Free twice: crash. Use after free: security vulnerability.
In Java and Python, a garbage collector handles memory automatically — at the cost of runtime overhead and unpredictable pauses.
Rust takes a third path: ownership rules baked into the type system. The compiler verifies at compile time that memory is always valid and never double-freed. No runtime overhead, no GC pauses, no dangling pointers.
Ownership
Every value in Rust has exactly one owner. When the owner goes out of scope, the value is dropped (memory freed):
fn main() {
let s = String::from("hello"); // s owns the String
// s goes out of scope here → String is freed
}
Ownership can be transferred (moved):
fn takes_ownership(s: String) {
println!("{}", s);
} // s is dropped here
fn main() {
let name = String::from("Rust");
takes_ownership(name);
// println!("{}", name); // ERROR: name was moved, no longer valid
}
For types that implement Copy (integers, floats, booleans, chars, and tuples of them), assignment copies the value instead of moving it:
fn main() {
let x = 5;
let y = x; // x is copied — both are valid
println!("x={}, y={}", x, y);
let s1 = String::from("hello");
let s2 = s1; // s1 is MOVED — not copied
// println!("{}", s1); // ERROR
println!("{}", s2);
}
Clone: Explicit Deep Copy
When you genuinely need a copy of heap data, call .clone():
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone(); // Deep copy — allocates new heap memory
println!("s1={}, s2={}", s1, s2); // Both valid
}
Use clone deliberately — it’s an allocation. If you find yourself cloning frequently, reconsider whether borrowing would work instead.
Borrowing
Instead of transferring ownership, you can lend a reference — a borrow:
fn print_string(s: &str) { // borrows s, doesn't take ownership
println!("{}", s);
}
fn main() {
let name = String::from("Alice");
print_string(&name); // lend a reference
println!("still own: {}", name); // still valid
}
The borrow checker enforces two rules simultaneously:
- You can have any number of immutable borrows (
&T) - OR exactly one mutable borrow (
&mut T) - Never both at the same time
fn main() {
let mut s = String::from("hello");
// Multiple immutable borrows — OK
let r1 = &s;
let r2 = &s;
println!("{} and {}", r1, r2); // Last use of r1, r2
// Now a mutable borrow is allowed (r1 and r2 are no longer used)
let r3 = &mut s;
r3.push_str(", world");
println!("{}", r3);
}
The rule prevents data races: if you can only mutate through one reference at a time, two threads can never race on the same data.
The Borrow Checker in Practice
The classic “fight with the borrow checker” usually means your code has an ownership design issue. Common patterns to restructure:
// BAD: holding reference while also mutating
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0]; // immutable borrow
v.push(4); // ERROR: mutable borrow while immutable exists
println!("{}", first);
}
// GOOD: copy the value first
fn main() {
let mut v = vec![1, 2, 3];
let first = v[0]; // copy the i32 (it's Copy)
v.push(4); // OK — no active borrow
println!("{}", first);
}
Lifetimes
Lifetimes describe how long references are valid. The compiler infers them in most cases, but some function signatures need explicit annotations:
// Without lifetime annotation — compiler rejects this
// (can't determine which input the output borrows from)
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() { x } else { y }
}
// With lifetime annotation — tells compiler output lives
// at most as long as both inputs
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 is long");
let result;
{
let s2 = String::from("xyz");
result = longest(s1.as_str(), s2.as_str());
println!("{}", result); // Used here — s2 still alive
}
}
Lifetime Elision Rules
The compiler applies three rules automatically to infer lifetimes:
- Each reference parameter gets its own lifetime
- If there’s exactly one input lifetime, it’s assigned to all outputs
- If one of the inputs is
&selfor&mut self, the self lifetime is assigned to output
This is why most functions don’t need explicit lifetime annotations.
Lifetimes in Structs
When a struct holds a reference, it needs a lifetime annotation:
#[derive(Debug)]
struct Excerpt<'a> {
text: &'a str, // struct can't outlive the str it borrows
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().unwrap();
let excerpt = Excerpt { text: first_sentence };
println!("{:?}", excerpt); // Excerpt { text: "Call me Ishmael" }
// excerpt cannot outlive `novel` — compiler enforces this
}
Structs and Data Modeling
Structs group related data:
#[derive(Debug, Clone)]
struct User {
username: String,
email: String,
active: bool,
login_count: u32,
}
impl User {
// Associated function (constructor pattern)
fn new(username: &str, email: &str) -> Self {
User {
username: username.to_string(),
email: email.to_string(),
active: true,
login_count: 0,
}
}
fn login(&mut self) {
self.login_count += 1;
}
fn deactivate(&mut self) {
self.active = false;
}
}
fn main() {
let mut user = User::new("alice", "[email protected]");
user.login();
user.login();
println!("{}: {} logins, active={}", user.username, user.login_count, user.active);
// Struct update syntax — copy remaining fields from another instance
let user2 = User {
email: "[email protected]".to_string(),
username: "bob".to_string(),
..user // copy active and login_count from user
};
println!("{:?}", user2);
}
Enums for Modeling Variants
Enums define types that can be one of several variants, each optionally holding different data:
#[derive(Debug)]
enum NetworkEvent {
Connected { host: String, port: u16 },
Disconnected,
MessageReceived(String),
Error { code: u32, message: String },
}
impl NetworkEvent {
fn is_error(&self) -> bool {
matches!(self, NetworkEvent::Error { .. })
}
}
fn handle(event: NetworkEvent) {
match event {
NetworkEvent::Connected { host, port } => {
println!("Connected to {}:{}", host, port);
}
NetworkEvent::Disconnected => {
println!("Disconnected");
}
NetworkEvent::MessageReceived(msg) => {
println!("Got: {}", msg);
}
NetworkEvent::Error { code, message } => {
eprintln!("Error {}: {}", code, message);
}
}
}
match is exhaustive — the compiler forces you to handle every variant. This prevents unhandled cases from becoming runtime bugs.
Error Handling: Result and Option
Rust has no null and no exceptions. Absence and failure are explicit types:
use std::num::ParseIntError;
use std::fs;
// Option<T> — value may or may not exist
fn find_first_even(v: &[i32]) -> Option<i32> {
v.iter().copied().find(|&x| x % 2 == 0)
}
// Result<T, E> — operation may succeed or fail
fn parse_port(s: &str) -> Result<u16, ParseIntError> {
s.parse::<u16>()
}
// The ? operator propagates errors early
fn read_and_parse(path: &str) -> Result<u16, Box<dyn std::error::Error>> {
let content = fs::read_to_string(path)?; // ? returns if Err
let port = content.trim().parse::<u16>()?; // ? returns if Err
Ok(port)
}
fn main() {
// Option
let nums = vec![1, 3, 5, 8, 9];
match find_first_even(&nums) {
Some(n) => println!("First even: {}", n),
None => println!("No even numbers"),
}
// Concise Option methods
let default_port = find_first_even(&[1, 3, 5]).unwrap_or(8080) as u16;
println!("Port: {}", default_port);
// Result
match parse_port("8080") {
Ok(port) => println!("Valid port: {}", port),
Err(e) => println!("Invalid: {}", e),
}
// Chain operations on Result
let doubled = parse_port("21").map(|p| p * 2);
println!("{:?}", doubled); // Ok(42)
}
Don’t Use unwrap() in Production
unwrap() and expect() panic on None/Err. Use them in tests and prototyping, but production code should propagate or handle errors:
// Testing/prototyping — ok
let x: i32 = "42".parse().unwrap();
// Production — handle the error
let x: i32 = "42".parse().unwrap_or(0);
let x: i32 = "42".parse().unwrap_or_else(|_| {
eprintln!("Parse failed, using default");
0
});
The Stack vs Heap Mental Model
Understanding where values live helps explain Rust’s rules:
Stack (fast, fixed size, auto-freed on scope exit):
- i32, f64, bool, char
- &T, &mut T (the pointer itself)
- fixed-size arrays [T; N]
- structs of stack types
Heap (slower, dynamic size, freed when owner drops):
- String (Vec<u8> internally)
- Vec<T>
- Box<T>
- Any type with dynamic size
When a String owner goes out of scope, Rust calls drop(), which frees the heap allocation. This is deterministic — no GC needed.
Common Beginner Pitfalls
1. Overusing clone
// Bad: clone to avoid borrow checker
fn get_name(user: &User) -> String { user.username.clone() }
// Better: return reference if you can
fn get_name(user: &User) -> &str { &user.username }
2. Fighting the borrow checker instead of redesigning
If you’re stuck, ask: “who should own this data?” Often the answer is to restructure so the data lives in one place and others borrow it.
3. Returning references to local variables
fn broken() -> &str {
let s = String::from("hello");
&s // ERROR: s is dropped at end of function, reference would dangle
}
// Fix: return owned String
fn fixed() -> String {
String::from("hello")
}
4. Forgetting that iterating consumes by default
let v = vec![1, 2, 3];
for x in v { println!("{}", x); } // v is consumed (into_iter())
// println!("{:?}", v); // ERROR
// If you need v afterward, iterate by reference
for x in &v { println!("{}", x); }
println!("{:?}", v); // still valid
Practical Learning Path
- Ownership + move semantics — understand why values can only be used once after moving
- Borrowing + borrow checker — immutable vs mutable, why you can’t do both simultaneously
- Structs + enums — building your own types
Option+Result+match— Rust’s error handling philosophy- Traits — shared behavior, generics, polymorphism
- Lifetimes — usually elided; learn when you need to annotate
- Closures + iterators — functional patterns, zero-cost abstractions
- Concurrency —
Send,Sync,Arc,Mutex
Don’t try to learn everything at once. The borrow checker becomes intuitive after you’ve refactored a few programs that don’t compile.
Summary
The three rules that underpin Rust’s memory model:
- Every value has exactly one owner
- When the owner goes out of scope, the value is dropped
- References must always be valid — you can have many
&TOR one&mut T, never both
These rules, enforced at compile time, eliminate:
- Use-after-free bugs
- Double-free bugs
- Null pointer dereferences
- Data races in concurrent code
The cost: a learning curve. The payoff: programs that are correct about memory by construction.
Resources
- The Rust Book
- Rust by Example
- Rustlings — hands-on exercises
- Rust Playground — experiment online
Comments