Ownership and Borrowing in Rust Programming Language: Complete Guide to Memory Safety

Ownership and Borrowing in Rust

Ownership and Borrowing in Rust

When I first started learning Rust, I remember staring at the compiler error value borrowed after move and genuinely wondering if I had made the wrong choice switching from C++. I hadn’t. What I was fighting wasn’t a bug in my understanding of programming — it was Rust politely refusing to let me write a memory bug. Once ownership and borrowing finally clicked for me, I stopped fighting the compiler and started treating it like a very strict, very helpful pair-programming partner.

In this guide, I’m going to walk you through ownership and borrowing the way I wish someone had explained it to me — starting from the absolute basics and working up to the kind of nuance you only really appreciate once you’ve shipped a few real projects.

Why Ownership Exists in the First Place

Most languages solve memory management in one of two ways. C and C++ hand you the keys and say “good luck” — you manually allocate and free memory, and if you get it wrong, you get dangling pointers, double frees, or memory leaks. Languages like Python, Java, or Go take the opposite approach — they run a garbage collector in the background that cleans up memory for you, at the cost of runtime overhead and unpredictable pauses.

Rust’s designers wanted something different: memory safety without a garbage collector. Their answer was ownership — a set of rules, checked entirely at compile time, that guarantees your program never has a dangling pointer, a data race, or a use-after-free bug. The best part is that this checking costs you nothing at runtime. It’s often called a “zero-cost abstraction” because the safety guarantees disappear once your code compiles — there’s no runtime tax for the safety you get.

The Three Rules of Ownership

Rust’s ownership model boils down to three rules:

  1. Each value in Rust has a single owner (a variable).
  2. There can only be one owner at a time.
  3. When the owner goes out of scope, the value is dropped (its memory is freed).

That’s it. Everything else in this article is really just exploring the consequences of these three rules.

Let’s see rule 3 in action with a simple example:

fn main() {
    {
        let name = String::from("Rustacean");
        println!("Hello, {}!", name);
    } // `name` goes out of scope here, and Rust automatically frees its memory
    // println!("{}", name); // This would fail — name no longer exists
}

Output:

Hello, Rustacean!

There’s no manual free() call anywhere. When name goes out of scope at the closing brace, Rust inserts a call to drop behind the scenes and cleans up the heap memory that String allocated.

Stack vs. Heap: Why This Matters for Ownership

To really understand ownership, you need to understand where your data lives. Simple, fixed-size types like i32, bool, or char live on the stack — they’re cheap to copy and Rust handles them without any ownership drama. Types like String, Vec<T>, or Box<T> store their actual data on the heap, with a small pointer/length/capacity structure on the stack that tracks it.

Ownership rules matter most for heap-allocated data, because heap memory needs to be explicitly freed at some point — and Rust needs to know exactly who is responsible for freeing it.

Move Semantics: What Happens When You Assign a Variable

This is the part that trips up almost everyone coming from another language. In Rust, assigning a heap-allocated value to a new variable doesn’t copy it — it moves it.

fn main() {
    let s1 = String::from("hello");
    let s2 = s1; // s1 is "moved" into s2

    println!("{}, world!", s2); // works fine
    // println!("{}, world!", s1); // ERROR: value borrowed after move
}

If you try to compile the commented-out line, you’ll get something like:

error[E0382]: borrow of moved value: `s1`

Why does Rust do this instead of copying the string data? Performance and safety, together. If Rust silently copied the heap data every time you assigned a variable, that would be expensive for large data structures. If instead it let both s1 and s2 point to the same heap memory without tracking ownership, you’d get a double-free the moment both variables went out of scope — a classic C++ bug. Rust’s solution: only one variable owns the data at a time. After the move, s1 is simply invalid, and the compiler enforces that at compile time — no runtime check needed.

If you genuinely want a deep copy, you call .clone() explicitly:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone(); // deep copy of heap data

    println!("s1 = {}, s2 = {}", s1, s2); // both valid
}

Notice that Rust makes copying explicit with .clone(). This is intentional — whenever you see .clone() in Rust code, you know exactly where a potentially expensive heap copy is happening.

Ownership and Functions

Passing a value into a function follows the exact same move semantics:

fn takes_ownership(some_string: String) {
    println!("I now own: {}", some_string);
} // some_string goes out of scope and is dropped here

fn main() {
    let s = String::from("borrowed for a moment");
    takes_ownership(s);
    // println!("{}", s); // ERROR: s was moved into the function
}

This is exactly why borrowing exists — because moving ownership into every function you call would make Rust incredibly painful to use.

Borrowing: Using Data Without Taking Ownership

Borrowing lets you access a value without taking ownership of it, using references (& for immutable borrows, &mut for mutable borrows).

fn calculate_length(s: &String) -> usize {
    s.len()
} // s goes out of scope, but because it doesn't own the data, nothing is dropped

fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1);

    println!("The length of '{}' is {}.", s1, len); // s1 is still valid!
}

Output:

The length of 'hello' is 5.

calculate_length borrows s1 instead of taking ownership, so s1 is still usable in main after the function call. This is the pattern you’ll use constantly in idiomatic Rust — pass references unless a function genuinely needs to own the data.

Mutable References

To modify borrowed data, you need a mutable reference:

fn append_exclamation(s: &mut String) {
    s.push_str("!");
}

fn main() {
    let mut s = String::from("Hello, Rust");
    append_exclamation(&mut s);
    println!("{}", s);
}

Output:

Hello, Rust!

The Borrowing Rules

The borrow checker enforces two rules at compile time, and they’re the heart of Rust’s data-race prevention:

  1. You can have either one mutable reference or any number of immutable references to a piece of data — but not both at the same time.
  2. References must always be valid (no dangling references).
fn main() {
    let mut s = String::from("hello");

    let r1 = &s; // immutable borrow
    let r2 = &s; // another immutable borrow — fine
    println!("{} and {}", r1, r2);

    let r3 = &mut s; // mutable borrow — allowed because r1 and r2 are no longer used
    r3.push_str(" world");
    println!("{}", r3);
}

If you tried to use r1 after creating r3, the compiler would reject it. This rule is what makes data races impossible in safe Rust — you literally cannot have a mutable reference and any other reference active at the same time, so there’s no way for two parts of your code to read and write the same memory concurrently by accident.

Lifetimes: How Rust Tracks Reference Validity

Lifetimes are Rust’s way of making sure references never outlive the data they point to. Most of the time, the compiler infers lifetimes automatically (“lifetime elision”), but sometimes you need to annotate them explicitly, especially in function signatures that return references:

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);
    }
}

The 'a annotation tells Rust: “the returned reference will live at least as long as the shorter of the two input lifetimes.” This doesn’t change how long anything actually lives — it just describes the relationship so the compiler can verify safety. If you tried to use result outside that inner scope, the compiler would catch it, because string2 (and therefore potentially result) wouldn’t be valid anymore.

Internal Working: How the Borrow Checker Actually Verifies This

Under the hood, the Rust compiler builds a control-flow graph of your program and tracks, for every reference, the region of code where it’s “alive” — this is sometimes called Non-Lexical Lifetimes (NLL), introduced a few years back to make the borrow checker smarter about when a reference actually stops being used, rather than just when it goes out of lexical scope. This is why the example above with r1, r2, and r3 compiles — the borrow checker sees that r1 and r2 aren’t used after their println!, so their “borrow” effectively ends early, even though the variables are still technically in scope.

Real-World Example: Ownership in a Small Inventory System

Here’s a slightly larger example that mirrors how ownership and borrowing show up in real projects:

struct Inventory {
    items: Vec<String>,
}

impl Inventory {
    fn new() -> Self {
        Inventory { items: Vec::new() }
    }

    fn add_item(&mut self, item: String) {
        self.items.push(item);
    }

    fn total_items(&self) -> usize {
        self.items.len()
    }

    fn print_all(&self) {
        for item in &self.items {
            println!("- {}", item);
        }
    }
}

fn main() {
    let mut warehouse = Inventory::new();
    warehouse.add_item(String::from("Laptop"));
    warehouse.add_item(String::from("Monitor"));

    println!("Total items: {}", warehouse.total_items());
    warehouse.print_all();
}

Output:

Total items: 2
- Laptop
- Monitor

Notice &mut self for methods that modify state and &self for read-only methods — this pattern is everywhere in idiomatic Rust, and it’s the borrow checker’s rules applied directly to your own structs.

Performance Implications

Because ownership and borrowing are resolved entirely at compile time, there’s no runtime cost for memory safety — no garbage collector pauses, no reference counting overhead (unless you explicitly opt into Rc<T> or Arc<T>). This is a big reason Rust is competitive with C and C++ for systems programming, game engines, and performance-critical services, while still preventing entire categories of bugs that plague those languages.

Common Mistakes and How to Debug Them

cargo new ownership_demo
cd ownership_demo
cargo check
cargo run

Best Practices for Idiomatic Ownership and Borrowing

FAQs

Q: Does Rust have a garbage collector? No. Memory is managed entirely through ownership rules checked at compile time, with no runtime garbage collector.

Q: What’s the difference between String and &str? String is an owned, growable, heap-allocated string. &str is a borrowed reference to string data (often a slice of a String or a string literal).

Q: Why can’t I have two mutable references at once? Because it would allow two parts of your code to modify the same data simultaneously, which is exactly the kind of data race Rust is designed to prevent at compile time.

Q: What if I really need multiple owners of the same data? Use Rc<T> (single-threaded) or Arc<T> (multi-threaded) for shared ownership with reference counting.

Q: Is borrowing slower than owning? No — a reference is just a pointer under the hood; there’s no runtime overhead beyond that of any pointer dereference.

Summary

Ownership and borrowing are the foundation everything else in Rust is built on. Once you internalize the three ownership rules and the two borrowing rules, a huge amount of what initially feels like fighting the compiler starts to feel like the compiler catching real bugs before they ever run. It took me a few weeks of genuine friction before this clicked, and I promise it’s worth pushing through — everything downstream in Rust, from smart pointers to concurrency, builds directly on these ideas.

References

Exit mobile version