$elementary.dev
[Courses][About]|
$elementary.dev

Text-first, project-driven courses for developers who want to level up by building real things.

CoursesAbout
Stay Updated

Get notified about new courses and articles.

// No spam, unsubscribe anytime

© 2026 elementary.dev
PrivacyTerms
// Course

Elementary Rust

5/11
  • 01Hello, Rust
  • 02Variables and Types
  • 03Functions and Control Flow
  • 04Why Memory Management Matters
  • 05Ownership: Rust's Solution
  • 06Borrowing and References
  • 07Slices
  • 08Structs
  • 09Enums and Pattern Matching
  • 10Organizing Rust Projects
  • 11Collections
courses/elementary-rust/05-ownership

Ownership: Rust's Solution

Learn the three ownership rules that give Rust its memory safety guarantees.

~25 min|#Lesson 5/11

Ownership: Rust's Solution

In the previous lesson, we saw how memory management is hard. Double frees, dangling pointers, use-after-free bugs. Other languages solve this with garbage collection, but that has tradeoffs.

Rust takes a different path. It enforces memory safety at compile time through a system called ownership.

The Three Rules

Every Rust programmer memorizes these:

  1. Each value in Rust has an owner.
  2. There can only be one owner at a time.
  3. When the owner goes out of scope, the value is dropped.

That's it. These three rules, enforced by the compiler, eliminate entire categories of memory bugs. Let's see them in action.

Scope and Drop

fn main() {
    let s = String::from("hello");
    // s is valid here

    // do stuff with s

} // s goes out of scope and is dropped

When a variable goes out of scope, Rust automatically calls a special function called drop. This is where the memory gets freed. No manual free() calls. No garbage collector. Just scope.

// Info-The Drop Trait

Any type can define custom cleanup logic by implementing the Drop trait. For String, dropping means freeing the heap memory. For a file handle, it means closing the file.

The String Type

To understand ownership, we need a type that uses the heap. Integers live entirely on the stack, so they're too simple for this lesson. Let's use String.

let s1 = String::from("hello");

A String is made of three parts stored on the stack:

  • ptr: A pointer to the heap data
  • len: How many bytes the string currently uses (5 for "hello")
  • capacity: How many bytes were allocated (at least 5)

The actual characters ("hello") live on the heap, pointed to by ptr.

This is different from a string literal like "hello". String literals are baked into the binary at compile time. They're immutable and have a fixed, known size. But String is growable. You can push characters onto it. That flexibility requires heap allocation.

let mut s = String::from("hello");
s.push_str(", world!");  // String can grow
println!("{s}");         // "hello, world!"

The heap allocation is what makes ownership interesting. When a String goes out of scope, that heap memory needs to be freed. But what if two variables point to the same heap data?

Move Semantics

Here's where ownership gets interesting. Watch what happens when we assign one String to another:

let s1 = String::from("hello");
let s2 = s1;

println!("{s1}"); // ERROR: borrow of moved value: `s1`

If you come from Python or JavaScript, this is surprising. In those languages, both variables would point to the same data.

Rust does something different. When we write let s2 = s1:

  1. The stack data (ptr, len, capacity) is copied to s2
  2. The heap data is NOT copied
  3. s1 is invalidated
// Warning-Value moved here

After let s2 = s1, using s1 is a compile error. Rust considers s1 "moved" into s2. This is enforced at compile time, not runtime.

Why does Rust do this? Think about what happens when both variables go out of scope. If s1 and s2 both pointed to the same heap data, Rust would try to free that memory twice. Double free. Memory corruption. Undefined behavior.

Rust's solution: after the move, only s2 owns the data. Only s2 can use it. Only s2 will call drop.

Visualizing a Move

Before the assignment:

s1: { ptr: 0x123, len: 5, capacity: 5 } -----> "hello" (heap)

After let s2 = s1:

s1: [invalid - cannot be used]
s2: { ptr: 0x123, len: 5, capacity: 5 } -----> "hello" (heap)

The stack data moved from s1 to s2. The heap data stays where it is. Only the ownership transferred. This is cheap - just copying a few bytes on the stack.

Rust does not have shallow copy in the traditional way. Since the source gets invalidated after this operation, we call it a "move."

// Challenge

Move or Copy?

After `let s2 = s1;` where s1 is a String, can you still use s1?

$

Clone for Deep Copy

What if you actually want a deep copy? Two independent Strings with their own heap data?

let s1 = String::from("hello");
let s2 = s1.clone();

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

The clone() method explicitly copies the heap data. Now s1 and s2 each own their own copy. When they go out of scope, each frees its own memory. No double free.

// Tip-Clone is explicit

Rust makes deep copies explicit with clone(). This is intentional. Heap allocations are expensive. You should know when you're doing one.

After cloning:

s1: { ptr: 0x123, len: 5, capacity: 5 } -----> "hello" (heap, at 0x123)
s2: { ptr: 0x456, len: 5, capacity: 5 } -----> "hello" (heap, at 0x456)

Two separate heap allocations. Two owners. Two eventual drop calls. Each cleaning up its own memory.

The Copy Trait

Some types don't need this complexity. Integers, floats, booleans, and characters live entirely on the stack. There's no heap data to worry about.

let x = 5;
let y = x;

println!("x = {x}, y = {y}"); // Both valid!

Wait, why does this work? We assigned x to y, but we can still use x.

Types that live entirely on the stack can implement the Copy trait. When a type is Copy, assignment creates a full copy of the value. No move happens. The original remains valid.

Here's what implements Copy:

  • All integers (i32, u64, etc.)
  • All floats (f32, f64)
  • bool
  • char
  • Tuples containing only Copy types
  • Fixed-size arrays containing only Copy types
// Info-Copy vs Clone

Copy happens implicitly on assignment. Clone must be called explicitly. A type cannot implement both Copy and custom Drop logic. If your type needs cleanup, it can't be Copy.

Ownership and Functions

Passing a value to a function follows the same rules. It's just like assignment.

fn main() {
    let s = String::from("hello");
    takes_ownership(s);
    // s is no longer valid here!

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

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

fn makes_copy(some_integer: i32) {
    println!("{some_integer}");
} // some_integer goes out of scope, nothing special happens

When we pass s to takes_ownership, ownership moves into the function. After the call, we can't use s anymore. The String's memory is freed when takes_ownership returns.

But x is i32, which is Copy. Passing it to makes_copy creates a copy. The original x remains valid.

This is a common pattern in Rust. You pass a String to a function and suddenly you can't use it anymore. The compiler is protecting you from double frees. But sometimes this is exactly what you want - you're done with the value, let the function have it.

// Tip-Intentional moves

Moving ownership into a function is often intentional. If you create a String, pass it to a function, and never need it again, moving is perfect. The function takes ownership and handles cleanup. You don't have to think about it.

Return Values Transfer Ownership

Functions can also transfer ownership back through return values:

fn gives_ownership() -> String {
    let s = String::from("yours");
    s  // Returned and moved out
}

fn takes_and_gives_back(s: String) -> String {
    s  // Returned, ownership transferred back
}

fn main() {
    let s1 = gives_ownership();         // s1 owns the String

    let s2 = String::from("hello");
    let s3 = takes_and_gives_back(s2);  // s2 moved in, result moved to s3

    // s2 is invalid, s3 is valid
}
// Danger-This gets tedious

Having to return values just to keep using them is painful. What if a function needs to use a value but you want to keep using it after? You'd have to pass it in and return it back out. Every. Single. Time.

This pattern works, but it's annoying:

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len();
    (s, length)  // Return the String back along with the result
}

fn main() {
    let s1 = String::from("hello");
    let (s2, len) = calculate_length(s1);
    println!("The length of '{s2}' is {len}.");
}

We had to return a tuple with the String and the length. Just to keep using our String. There has to be a better way.

The Pattern Emerges

Let's think about what we want:

  • Use a value without taking ownership
  • Let the original owner keep owning it
  • Read or modify it temporarily

Ownership says there can only be one owner. But what about borrowing? You can let someone use your book without giving it to them. They just have to give it back.

That's exactly what references do.

// Challenge

Ownership Puzzle

What trait must a type implement to be copied instead of moved on assignment?

$

What We Learned

Ownership is Rust's core memory safety mechanism:

  1. Every value has exactly one owner - No ambiguity about who frees the memory
  2. Ownership can transfer (move) - Assignment and function calls move non-Copy types
  3. Values are dropped when owners go out of scope - Automatic cleanup, no leaks
  4. Copy types are duplicated, not moved - Simple stack types like integers
  5. Clone creates explicit deep copies - When you need independent heap data

This system eliminates double frees, use-after-free, and memory leaks. All at compile time. No runtime cost.

But passing ownership back and forth is tedious. Next, we'll learn about borrowing - using values without taking ownership.

<-[prev]Why Memory Management Matters[next]->Borrowing and References
Progress: 0%
// On this page
  • The Three Rules
  • Scope and Drop
  • The String Type
  • Move Semantics
  • Visualizing a Move
  • Clone for Deep Copy
  • The Copy Trait
  • Ownership and Functions
  • Return Values Transfer Ownership
  • The Pattern Emerges
  • What We Learned