Rust Ownership and Borrowing: A Complete Guide
Rust’s ownership system is its most distinctive feature. It enables memory safety without a garbage collector by enforcing strict rules at compile time. While the borrow checker can feel restrictive when you are starting out, it catches bugs that would be runtime crashes in C++ or null pointer exceptions in other languages.
This guide explains ownership, borrowing, references, and lifetimes with practical examples.
Ownership Rules
Rust follows three simple rules:
- Each value has exactly one owner
- There can only be one owner at a time
- When the owner goes out of scope, the value is dropped
fn main() {
// s is the owner of the string
let s = String::from("hello");
// s goes out of scope here, and the string is freed
--- // drop is called automatically
Move Semantics
fn main() {
let s1 = String::from("hello");
let s2 = s1; // s1 is MOVED to s2
// println!("{}", s1); // ERROR: s1 is no longer valid
println!("{}", s2); // OK: s2 now owns the string
---When s1 is assigned to s2, the string data is not copied — the pointer is moved. Rust invalidates s1 to prevent double-free errors.
Clone for Deep Copy
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone(); // Deep copy — both are valid
println!("s1 = {}, s2 = {}", s1, s2); // Both work
---Copy Trait
Types stored entirely on the stack implement the Copy trait:
fn main() {
let x = 5;
let y = x; // x is COPIED (not moved)
println!("x = {}, y = {}", x, y); // Both valid
// Common Copy types: integers, floats, booleans, chars, tuples of Copy types
---Borrowing
Instead of transferring ownership, you can borrow a reference to a value.
Immutable References
fn calculate_length(s: &String) -> usize {
s.len()
--- // s goes out of scope but does NOT drop the value
fn main() {
let s = String::from("hello");
let len = calculate_length(&s);
println!("'{}' has length {}", s, len); // s is still valid
---Mutable References
fn change(s: &mut String) {
s.push_str(", world");
---
fn main() {
let mut s = String::from("hello");
change(&mut s);
println!("{}", s); // "hello, world"
---The Borrowing Rules
- You can have any number of immutable references (
&T) - You can have exactly one mutable reference (
&mut T) - You cannot have both immutable and mutable references simultaneously
fn main() {
let mut s = String::from("hello");
let r1 = &s; // OK
let r2 = &s; // OK — multiple immutable references allowed
// let r3 = &mut s; // ERROR: cannot borrow as mutable because immutable borrows exist
println!("{} and {}", r1, r2);
// r1 and r2 are no longer used here
let r3 = &mut s; // OK — no more immutable references in use
r3.push_str(" world");
---Dangling References
Rust prevents dangling references at compile time:
fn dangle() -> &String {
let s = String::from("hello");
&s // ERROR: returns reference to local variable
--- // s is dropped here, so the reference would be dangling
Lifetimes
Lifetimes ensure that references are valid for the duration of their use.
Explicit Lifetime Annotations
// &i32 — a reference
// &'a i32 — a reference with an explicit lifetime
// &'a mut i32 — a mutable reference with an explicit lifetime
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
---
fn main() {
let string1 = String::from("long string is long");
let result;
{
let string2 = String::from("xyz");
result = longest(string1.as_str(), string2.as_str());
println!("The longest string is {}", result);
}
// println!("{}", result); // ERROR: string2 does not live long enough
---Lifetime Elision Rules
The compiler can infer lifetimes in common patterns:
// No need for explicit lifetimes because:
// 1. Each parameter that is a reference gets its own lifetime
// 2. If there is one input lifetime, it's assigned to all output references
// 3. If there is &self, its lifetime is assigned to output references
fn first_word(s: &str) -> &str { // Elided: fn first_word<'a>(s: &'a str) -> &'a str
s.split_whitespace().next().unwrap_or("")
---Lifetime in Structs
struct Excerpt<'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 = Excerpt { part: first_sentence };
println!("{}", excerpt.part);
---Static Lifetime
'static means the reference lives for the entire program duration:
// String literals have 'static lifetime
let s: &'static str = "I live forever";Common Patterns
Passing References to Functions
fn process_data(data: &Vec<i32>) -> i32 {
data.iter().sum()
---
fn modify_data(data: &mut Vec<i32>) {
data.push(42);
---Returning References from Functions
fn get_first<'a>(items: &'a [i32]) -> Option<&'a i32> {
items.first()
---Multiple References with Different Lifetimes
fn longest_with_announcement<'a, 'b>(
x: &'a str,
y: &'a str,
announcement: &'b str,
) -> &'a str {
println!("Announcement: {}", announcement);
if x.len() > y.len() { x } else { y }
---Common Compiler Errors and Fixes
// ERROR: cannot borrow `x` as mutable more than once at a time
fn main() {
let mut x = 5;
let r1 = &mut x;
let r2 = &mut x; // ERROR
println!("{}, {}", r1, r2);
---
// FIX: use a new scope
fn main() {
let mut x = 5;
{
let r1 = &mut x;
*r1 += 1;
} // r1 goes out of scope
let r2 = &mut x; // OK
*r2 += 1;
println!("{}", x);
---
// ERROR: cannot return reference to local variable
fn returns_ref() -> &i32 {
let x = 5;
&x
---
// FIX: return the value directly
fn returns_val() -> i32 {
5
---
// FIX: take ownership of a parameter and return a reference to it
fn returns_ref_to_param<'a>(x: &'a i32) -> &'a i32 {
x
---Conclusion
Rust’s ownership system eliminates entire categories of bugs — use-after-free, double-free, dangling pointers, and data races. The rules are strict, but they become intuitive with practice. Remember: each value has one owner, references borrow without owning, and lifetimes ensure references are always valid. When the borrow checker rejects your code, listen to it — it is preventing a bug.
Related: Check our Rust Error Handling guide.
Interior Mutability
Rust’s borrow rules are enforced at compile time by default, but interior mutability patterns provide controlled runtime mutability. Cell<T> enables mutation through shared references for Copy types. RefCell<T> enforces borrow rules at runtime for non-Copy types, panicking if the rules are violated. Mutex<T> and RwLock<T> extend interior mutability to multi-threaded contexts. These are essential tools for implementing patterns like caching, reference counting (Rc and Arc), and observable state.
Lifetime Elision Rules
Rust’s lifetime elision rules reduce annotation boilerplate for common patterns. The rules are: each elided input lifetime becomes a distinct lifetime parameter, and if there is exactly one input lifetime, it is assigned to all elided output lifetimes. When these rules don’t apply, you must annotate lifetimes explicitly. Understanding when elision applies and when it doesn’t is key to writing clean Rust.
Rust in Production: Case Studies
Rust has been adopted in production by major technology companies for performance-critical and safety-critical systems. Mozilla uses Rust in the Firefox browser engine (Servo/Quantum) for improved memory safety and performance in CSS layout and rendering. Dropbox rewrote their sync engine core in Rust, achieving significant performance improvements and eliminating entire classes of bugs. Cloudflare uses Rust for edge computing infrastructure, including their Pingora HTTP proxy that handles 10% of the world’s internet traffic. Figma rewrote their multiplayer editing engine in Rust, achieving predictable performance and eliminating crashes. Discord used Rust for their read states service, handling millions of concurrent connections with minimal resources. These case studies demonstrate Rust’s value in systems where performance, reliability, and memory safety are critical. The common pattern: rewrite performance-critical components in Rust while maintaining the rest of the system in the original language, proving that incremental adoption works in practice.
Embedded Systems Programming
Rust is gaining traction in embedded systems for its memory safety guarantees without a garbage collector or runtime. The cortex-m-quickstart template provides a starting point for ARM Cortex-M microcontrollers. The RTIC framework provides real-time interrupt-driven concurrency. Embassy offers an async embedded runtime with USB, networking, and BLE support. Rust’s type system prevents common embedded errors like misconfiguring peripherals, incorrect register access, and data races between interrupt handlers and main code. The embedded ecosystem continues to mature with HAL implementations for major microcontroller families from STM, Nordic, and Espressif.
FAQ
What background knowledge is recommended? A basic understanding of programming fundamentals will help, but many concepts are explained from first principles.
How can I practice these skills? Start with small projects, contribute to open source, and build a portfolio. Consistent practice is more effective than occasional deep dives.
What tools do I need to get started? Most topics require only a text editor, the relevant runtime, and package manager. Specific tool recommendations are included throughout.
For a comprehensive overview, read our article on Getting Started With Rust.