Ownership and Borrowing: The Core of Rust Memory Safety

🔊

This article is based on Rust 1.80+.

If you have a Go or Java background, the ownership system might be the most confusing concept in Rust. Go has a garbage collector (GC), and you rarely need to worry about when memory is freed. Rust takes a completely different approach—compile-time ownership rules guarantee memory safety, with zero runtime overhead.

The ownership system is the soul of Rust. It solves two classic problems: how to guarantee memory safety without using GC, and how to avoid data races. In other languages, these problems either require runtime checks or require extreme developer caution.

The Three Ownership Rules

Rust’s ownership system is built on three simple rules:

  1. Each value has a single owner
  2. At any given time, there can only be one owner
  3. When the owner leaves scope, the value is dropped (automatically freed)

Let’s understand each rule one by one.

Rule 1: Each Value Has a Single Owner

In Rust, each value is bound to a variable—that variable is the owner of the value.

rust
1
2
let x = 5;       // x is the owner of integer 5
let s = String::from("hello");  // s is the owner of String

Rule 2: At Any Given Time, There Can Only Be One Owner

When ownership is transferred, the original owner immediately becomes invalid. This is Move semantics.

rust
1
2
3
4
5
let s1 = String::from("hello");
let s2 = s1;  // Ownership transfers from s1 to s2

// println!("{}", s1);  // Compile error! s1 is no longer valid
println!("{}", s2);  // ✅ s2 is valid

This compile error usually looks like this:

1
2
3
4
5
6
7
8
error[E0382]: use of moved value: `s1`
 --> src/main.rs:4:20
  |
2 |     let s2 = s1;
  |         -- value moved here
3 |
4 |     println!("{}", s1);
  |                    ^^ value used here after move

Rule 3: Automatically Freed When Leaving Scope

When the owner leaves scope, Rust automatically calls the drop function to clean up memory. This is similar to C++’s RAII, but without manual implementation.

rust
1
2
3
{
    let s = String::from("hello");
}  // s leaves scope, drop is called, memory is automatically freed

Move Semantics: Copy Types vs Move Types

Types in Rust are divided into two categories:

Copy Types (Simple Stack Types)

Types that implement the Copy trait are automatically copied on assignment, keeping the original variable valid:

rust
1
2
3
4
let x = 5;
let y = x;  // x is copied to y
println!("{}", x);  // ✅ x is still valid
println!("{}", y);  // ✅ y is also valid

Common Copy types:

  • All integer types (i32, u64, usize, etc.)
  • Floating-point numbers (f32, f64)
  • Boolean values (bool)
  • Characters (char)
  • Tuples (if all elements are Copy types)

Move Types (Complex Heap Types)

Types that don’t implement the Copy trait are moved on assignment:

rust
1
2
3
let s1 = String::from("hello");
let s2 = s1;  // s1's ownership moves to s2
// println!("{}", s1);  // ❌ Compile error

Common Move types:

  • String
  • Vec<T>
  • Box<T>
  • HashMap<K, V>
  • User-defined struct (default is Move type)

Why Distinguish?

Copy types have known size at compile time and are completely stored on the stack, so copying is cheap. Move types typically contain heap allocation, so copying is expensive—Rust chooses to move instead of deep copy to avoid unnecessary performance overhead.

If you need deep copy, explicitly call .clone():

rust
1
2
3
4
let s1 = String::from("hello");
let s2 = s1.clone();  // Deep copy, both are valid
println!("{}", s1);  // ✅
println!("{}", s2);  // ✅

Ownership Transfer in Function Calls

Passing arguments also triggers ownership transfer:

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
fn takes_ownership(s: String) {
    println!("{}", s);
}  // s leaves scope, drop is called

fn makes_copy(i: i32) {
    println!("{}", i);
}  // i is Copy type, no special handling when leaving scope

fn main() {
    let s = String::from("hello");
    takes_ownership(s);
    // println!("{}", s);  // ❌ Compile error: s's ownership has been moved

    let x = 5;
    makes_copy(x);
    println!("{}", x);  // ✅ x is still valid
}

Borrowing: Access Without Ownership Transfer

What if we want a function to access data without taking ownership? Use references.

Immutable References (&T)

Use & to create an immutable reference, allowing multiple references to exist simultaneously:

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
fn calculate_length(s: &String) -> usize {
    s.len()
}  // s leaves scope, but because it's a reference, drop is not called

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

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

Mutable References (&mut T)

Use &mut to create a mutable reference, allowing data modification, but with strict restrictions:

rust
1
2
3
4
5
6
7
8
9
fn append_world(s: &mut String) {
    s.push_str(", world!");
}

fn main() {
    let mut s = String::from("hello");
    append_world(&mut s);
    println!("{}", s);  // Output "hello, world!"
}

Borrowing Rules

Rust’s borrow checker enforces the following rules at compile time:

  1. At any given time, you can have one mutable reference OR multiple immutable references
  2. References must always be valid (no dangling)
  3. Cannot have both mutable and immutable references simultaneously

Rule 1: Mutable vs Immutable

rust
1
2
3
4
5
6
7
8
let mut s = String::from("hello");

let r1 = &s;      // Immutable reference
let r2 = &s;      // ✅ Can have multiple immutable references
println!("{} {}", r1, r2);

let r3 = &mut s;  // ❌ Compile error: cannot create mutable reference when immutable references exist
println!("{}", r3);

Compile error:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:14
  |
2 |     let r1 = &s;
  |              -- immutable borrow occurs here
...
4 |     let r2 = &s;
  |              -- immutable borrow occurs here
...
6 |     let r3 = &mut s;
  |              ^^^^^^ mutable borrow occurs here

But if you create a mutable reference after immutable references are no longer used, it’s allowed:

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
let mut s = String::from("hello");

{
    let r1 = &s;
    let r2 = &s;
    println!("{} {}", r1, r2);
}  // r1 and r2 leave scope, no longer used

let r3 = &mut s;  // ✅ Now can create mutable reference
println!("{}", r3);

Rule 2: Preventing Dangling References

Dangling references are references that point to freed memory—this is a common security vulnerability in C/C++. Rust’s compiler can completely prevent this at compile time:

rust
1
2
3
4
5
6
7
8
9
fn dangle() -> &String {
    let s = String::from("hello");

    &s  // ❌ Compile error: returning reference to `s`, but `s` will be dropped
}

fn main() {
    let reference_to_nothing = dangle();
}

Compile error:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
error[E0106]: missing lifetime specifier
 --> src/main.rs:1:16
  |
1 | fn dangle() -> &String {
  |                ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `Owned` type instead
  |
1 | fn dangle() -> String {
  |                ~~~~~~~

The compiler tells you: the function returns a reference, but there’s no value to borrow from—because s will be dropped at the end of the function.

The correct approach is to return String itself (transfer ownership):

rust
1
2
3
4
fn no_dangle() -> String {
    let s = String::from("hello");
    s  // ✅ Ownership transferred to caller
}

Rule 3: Data Race Prevention

Data races occur when:

  • Two or more pointers access the same data simultaneously
  • At least one pointer is writing
  • No synchronization mechanism

Rust’s borrowing rules can prevent data races at compile time:

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
use std::thread;

fn main() {
    let mut data = vec![1, 2, 3, 4];

    let handle = thread::spawn(|| {
        let r1 = &mut data[0];  // ❌ Compile error: data might be accessed by another thread
        *r1 += 1;
    });

    println!("{:?}", data);  // Simultaneously accessing data
    handle.join().unwrap();
}

This code cannot compile because Rust knows data might be mutably borrowed in another thread while the main thread is accessing it.

The correct approach is to use concurrency primitives like Arc<Mutex<T>>:

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let data = Arc::new(Mutex::new(vec![1, 2, 3, 4]));

    let handle = thread::spawn({
        let data = Arc::clone(&data);
        move || {
            let mut vec = data.lock().unwrap();
            vec[0] += 1;
        }
    });

    {
        let vec = data.lock().unwrap();
        println!("{:?}", *vec);
    }
    handle.join().unwrap();
}

Comparison with Go’s Garbage Collection

Go’s Approach: Runtime GC

Go’s garbage collector tracks which variables are still in use at runtime and which can be safely freed. The advantage is good developer experience—you don’t need to manually manage memory. The disadvantages are:

  • GC pauses: Collection suspends program execution, unpredictable latency
  • Memory overhead: Needs to maintain GC metadata, actual memory usage doubles
  • No full control: Tuning GC parameters is an esoteric art

Rust’s Approach: Compile-time Checks

Rust determines when to free memory at compile time through ownership rules, with zero runtime overhead:

DimensionGo (GC)Rust (Ownership)
Memory release timingDetermined by runtime GCDetermined at compile time, freed immediately when leaving scope
Runtime overheadGC pauses, metadata maintenanceZero cost
Latency predictabilityUnpredictableCompletely predictable
Developer controlLowHigh
Memory safetyGC guarantees freeing, not leaksCompiler guarantees
Concurrency safetyRequires developer careCompiler prevents data races

Why Rust Doesn’t Need GC?

Rust’s design philosophy is: replace runtime reclamation with compile-time checks.

When a variable leaves scope, the compiler inserts a drop call. Because ownership rules guarantee that each value has only one owner, the compiler can precisely know when to free memory without needing runtime tracking like GC.

rust
1
2
3
4
{
    let s = String::from("hello");
    // Use s
}  // Compiler automatically inserts drop(&mut s)

Common Misconceptions for Go Developers

  1. Thinking Rust has GC: Rust has no runtime GC, memory release is deterministic.
  2. Using .clone() to avoid compile errors: This causes unnecessary deep copies, should use references for passing.
  3. Thinking ownership rules are too strict: These rules become natural once familiar, and compiler error messages are very friendly.

Common Misconceptions for Rust Developers

  1. Overusing .clone(): Prioritize references, clone only when necessary.
  2. Ignoring lifetime annotations: Although the compiler can often infer, complex scenarios require explicit annotations.
  3. Avoiding unsafe: Some low-level operations need unsafe, but be extremely cautious.

Introduction to Lifetimes

You might notice that the code above rarely uses lifetime annotations ('a). This is because Rust’s compiler is smart enough to infer in most cases.

rust
1
2
3
4
5
6
7
8
fn longest(x: &str, y: &str) -> &str {
    // This function cannot compile! The compiler doesn't know if the return value borrows from x or y
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

Need to explicitly annotate lifetime:

rust
1
2
3
4
5
6
7
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

The lifetime annotation 'a tells the compiler: the returned reference has the same shortest lifetime as the input references. This way the compiler can guarantee that the returned reference won’t dangle.

In-depth explanation of lifetimes is the content of the next article, here we just preview its existence.

Summary

The ownership system is Rust’s cornerstone, allowing the compiler to guarantee memory safety and concurrency safety at compile time. Key takeaways:

  • Three ownership rules: one value has one owner, move semantics, automatically freed when leaving scope
  • Copy vs Move types: simple types automatically copy, complex types transfer ownership
  • Borrowing rules: at any given time either multiple immutable references OR one mutable reference, cannot mix
  • Preventing dangling references: compiler guarantees references are always valid
  • Comparison with Go GC: Rust replaces runtime GC with compile-time checks, zero cost but steep learning curve
  • Lifetimes: annotate the valid range of references, prevent dangling references (deep dive in next chapter)

The ownership rules seem cumbersome at first, but once you understand the design philosophy behind them, you’ll find that they make Rust code’s memory behavior completely predictable and verifiable. This is exactly how Rust achieves memory safety without sacrificing performance.

The next article will dive deep into lifetime annotations, understanding how they let the compiler guarantee reference safety even in complex scenarios.