Rust’s ownership system, often a stumbling block for newcomers, is actually the primary mechanism that allows Rust to guarantee memory safety without a garbage collector.

Let’s see this in action. Imagine we have a simple function that takes a string and prints its length.

fn print_length(s: String) {
    println!("The length of the string is: {}", s.len());
}

fn main() {
    let my_string = String::from("hello");
    print_length(my_string);
    // println!("{}", my_string); // This line would cause a compile-time error
}

In main, my_string is created. When print_length(my_string) is called, ownership of the String data moves from main to the print_length function. Once print_length finishes, the String it owns is dropped, and its memory is freed. This is why the commented-out println! in main would fail: my_string no longer owns the data; it has been moved.

The core idea is that each value in Rust has a variable that’s called its owner. There can only be one owner at a time. When the owner goes out of scope, the value will be dropped.

This "move" semantics by default is what prevents double-frees and dangling pointers. If a value were simply copied (like primitive types i32, f64, bool, char, and tuples containing only these types), the original owner would still have access, and you could end up with multiple owners or invalid pointers.

Consider this:

fn main() {
    let x = 5; // i32, a primitive type that implements the Copy trait
    let y = x; // y gets a copy of x's value
    println!("x = {}, y = {}", x, y); // This is perfectly fine
}

Here, x is copied to y. Both x and y are valid and can be used. This is because i32 implements the Copy trait, which tells Rust that it’s cheap to copy and that copying doesn’t involve complex resource management like heap allocation.

Now, what if we want to use a value in multiple places without transferring ownership? That’s where borrowing comes in, using references (& and &mut).

fn calculate_length(s: &String) -> usize {
    s.len()
} // s goes out of scope here, but because it's a reference, nothing is dropped.

fn main() {
    let my_string = String::from("hello");
    let length = calculate_length(&my_string); // Pass a reference (borrow)
    println!("The length of '{}' is {}.", my_string, length); // my_string is still valid here
}

In calculate_length, s is a borrow of my_string. We are not giving ownership of my_string to the function; we are just letting it look at the data. The & symbol creates a reference, which is a pointer to a value that another variable owns. Crucially, references are immutable by default.

To allow modification, we use mutable references (&mut). However, Rust has a strict rule: at any given time, you can have either one mutable reference OR any number of immutable references to a particular piece of data, but not both.

fn change(s: &mut String) {
    s.push_str(", world");
}

fn main() {
    let mut my_string = String::from("hello");

    // let r1 = &my_string; // immutable borrow
    // let r2 = &my_string; // another immutable borrow - OK
    // let r3 = &mut my_string; // mutable borrow - NOT OK if r1 or r2 are still in scope

    // println!("{}, {}", r1, r2); // If these are used, r3 cannot be created here

    change(&mut my_string); // mutable borrow
    println!("{}", my_string); // my_string is still valid and modified
}

This rule is the heart of preventing data races. If multiple threads could have mutable access to the same data simultaneously, you’d have a data race. Rust’s borrow checker enforces this at compile time, ensuring that even in concurrent scenarios, your code is safe from data races.

The borrow checker also enforces lifetime rules, ensuring that references never outlive the data they point to. This is often implicitly handled, but sometimes you need to be explicit.

// This function returns a reference, so we need to tell the borrow checker
// that the returned reference's lifetime is tied to the shortest of the input references' lifetimes.
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("abcd");
    let string2 = "xyz"; // string literal, has 'static lifetime

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {}", result);
}

In longest, the <'a> syntax introduces a generic lifetime parameter named 'a. The annotations &'a str mean that the input references x and y must live at least as long as lifetime 'a, and the returned reference also has lifetime 'a. The compiler then infers that 'a must be the shorter of the lifetimes of string1 and string2. This prevents result from potentially pointing to memory that has already been freed.

The most subtle aspect of ownership and borrowing is how it interacts with data structures that contain references, such as linked lists or trees. If you’re not careful, you can easily create situations where the borrow checker cannot prove that all references are valid, leading to compile-time errors. This is often the source of what people call "fighting the borrow checker." The solution usually involves rethinking how you structure your data or how you pass ownership/borrowing, sometimes using smart pointers like Rc (Reference Counting) or Arc (Atomic Reference Counting) for shared ownership across multiple owners, or RefCell for interior mutability when you need mutable access within an immutable context (though this bypasses compile-time checks and moves checks to runtime).

The next concept you’ll likely encounter after mastering ownership and borrowing is how these principles apply to concurrency and threads, specifically how Rust’s safety guarantees extend to multi-threaded applications.

Want structured learning?

Take the full Rust course →