DEV Community

Cover image for Rust Ownership, Borrowing, and Lifetimes: Write Safer, Faster Systems Code
Nithin Bharadwaj
Nithin Bharadwaj

Posted on

Rust Ownership, Borrowing, and Lifetimes: Write Safer, Faster Systems Code

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

Memory management often feels like trying to organize a busy kitchen with too many cooks. Someone is using a knife, another needs the same cutting board, and ingredients risk being spoiled if not used at the right time. In programming, this chaos has a name: memory errors. They cause programs to crash in confusing ways or, worse, create security holes.

For decades, programmers faced a difficult choice. They could use languages like C or C++, which give complete control over memory. This is powerful, like having expert chefs who know every detail of the kitchen. But it’s risky. A single mistake—using an ingredient that’s already been thrown away, or two chefs trying to chop on the same board at once—can ruin the entire meal. These mistakes are called dangling pointers, use-after-free errors, and buffer overflows.

The other choice was languages like Java, Python, or Go. These provide a helpful kitchen manager—a garbage collector. This manager follows you around, watching what you use and cleaning up after you. It prevents many catastrophic errors, but it comes at a cost. The manager occasionally needs to stop all cooking to take out the trash, causing unpredictable pauses. You also have less direct control over where things are placed.

I found myself frustrated with this trade-off. I wanted the fine control for efficient systems, but I was tired of the anxiety that came with it. Then I started working with Rust. Rust approaches the problem differently. Instead of relying on a vigilant human or a runtime manager, it builds a set of rules right into the language. The compiler itself acts as a meticulous head chef who plans the entire kitchen workflow before any cooking begins. It checks that every tool is used by only one person at a time and that nothing is used after it’s been cleaned. If your plan breaks these rules, the compiler stops you and asks you to fix it. The program won’t even compile until the plan is sound.

This system is called ownership. It’s the heart of Rust. It might sound complex, but its goal is simple: to prevent errors before your program runs.

What is Ownership?

At its core, ownership is about who is responsible for a piece of data. In Rust, every value has a single, clear owner. This is usually the variable it’s bound to. When the owner goes out of scope—when the function ends, for example—the value is dropped, and its memory is freed. This is automatic and guaranteed.

Think of it like a document in an office. There’s one official file folder (the owner). If you need to give that document to a colleague in another department, you hand over the entire folder. You no longer have it. This prevents two people from having the original and making conflicting edits. Let’s see this in code.

fn main() {
    // `s1` is the owner of the String data "hello".
    let s1 = String::from("hello");

    // When we assign `s1` to `s2`, ownership *moves*.
    // `s1` is no longer valid. The folder has been handed off.
    let s2 = s1;

    // This line would cause a compile-time error:
    // println!("{}", s1); // ERROR: value borrowed here after move

    // `s2` is now the sole owner and works perfectly.
    println!("{}", s2); // This prints "hello"
}
Enter fullscreen mode Exit fullscreen mode

This is called a move. The data itself isn’t copied around in memory (which would be slow); instead, Rust copies the "metadata" about where the data is and changes the owner. The old variable becomes inert. The compiler enforces this, so you can’t accidentally use s1 after the move. This single rule prevents a whole class of "double free" errors, where two parts of a program try to release the same memory.

But what if you just want to let someone look at the document without taking the folder? You wouldn’t hand over the original; you’d give them a photocopy or let them read it at your desk. In Rust, this is called borrowing.

Borrowing: Sharing Without Stealing

Borrowing lets you create references to data without taking ownership. You can have either:

  • One mutable reference (&mut T): Someone can read and edit the document.
  • Any number of immutable references (&T): Many people can read the document, but no one can change it.

The compiler enforces these rules strictly. You cannot have a mutable reference while any immutable references exist, and you can only have one mutable reference at a time. This prevents data races at compile time—a situation where one thread is writing data while another is reading it, leading to unpredictable results.

fn main() {
    let mut document = String::from("Draft Text");

    // Create an immutable reference. `reader1` can read but not change.
    let reader1 = &document;
    println!("Reader 1 sees: {}", reader1);

    // We can create another immutable reference. Multiple readers are fine.
    let reader2 = &document;
    println!("Reader 2 sees: {}", reader2);

    // The readers are used here, their scope effectively ends after this.

    // NOW we can create a mutable reference, because no other references are active.
    let editor = &mut document;
    editor.push_str(" - Edited.");
    println!("After edit: {}", editor);
}
Enter fullscreen mode Exit fullscreen mode

The following code, however, will not compile. The compiler sees the conflict.

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

    let ref1 = &data[0]; // Immutable borrow of `data` starts here.
    // let ref_mut = &mut data[1]; // ERROR! Cannot borrow `data` as mutable...
    // ...because it is also borrowed as immutable (by `ref1`).

    println!("{}", ref1); // `ref1` is used here, so its borrow is considered in use until here.
}
Enter fullscreen mode Exit fullscreen mode

This feels restrictive at first. You might think, "But I know I’m only reading from one index and writing to another!" The compiler doesn’t assume that. It sees that both operations work on the same data vector. This strictness is what makes Rust safe. It forces you to structure your code in a way that makes these access patterns explicit and safe, often leading to better designs.

Lifetimes: How Long Do Borrows Last?

Borrowing introduces a new question: how long is a reference valid? If I give you a pointer to a piece of data inside my function, and my function ends, that data is gone. Your pointer would point to garbage—a dangling reference. Rust solves this with lifetimes.

A lifetime is a label for how long a reference is expected to live. The compiler uses these labels to check that all your references are valid. Often, the compiler can figure them out automatically. But sometimes, especially with structs or complex functions, you need to annotate them yourself.

It’s like lending a book from a library. The book (the data) has a lifetime—it exists in the library. Your loan (the reference) has a duration. You can’t keep the loaned book after the library has closed and destroyed its inventory. Lifetimes make this contract explicit in the code.

Here’s a common example where a struct holds a reference:

// `'a` is a lifetime annotation. It says: "An instance of `BookQuote`
// cannot outlive the `content` it references."
struct BookQuote<'a> {
    content: &'a str, // This is a string slice, a reference to part of a String.
    page: usize,
}

fn main() {
    let book_text = String::from("Call me Ishmael. Some years ago...");
    let first_line;

    // This block creates a scope.
    {
        // `quote` borrows a part of `book_text`.
        let quote = BookQuote {
            content: &book_text[0..15], // "Call me Ishmael."
            page: 1,
        };
        // `first_line` tries to take a reference from `quote`.
        // first_line = quote.content; // This would be problematic if we tried to use it later.
    } // `quote` goes out of scope here. Its borrow ends.

    // `book_text` is still valid here.
    println!("The book starts: {}", &book_text[0..15]);

    // But `first_line` could not point to `quote.content` here, because `quote` is gone.
    // The compiler's lifetime checking prevents us from writing that bug.
}
Enter fullscreen mode Exit fullscreen mode

When you write functions that return references, lifetimes become crucial. You’re telling the compiler about the relationship between the input references and the output reference.

// This function takes two string slices and returns the longest one.
// The lifetime `'a` tells Rust: "The returned reference will live as long as
// the shortest-lived of the two input 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("short");
    let result;
    {
        let string2 = String::from("a much longer string");
        // Both `string1` and `string2` are considered to have the same lifetime `'a`
        // for this call, which will be the shorter of their two actual lifetimes (string2's).
        result = longest(string1.as_str(), string2.as_str());
        println!("The longest string is {}", result); // This works.
    } // `string2` goes out of scope and is dropped here.
    // println!("The longest string is {}", result); // ERROR! `result` might refer to `string2` which is gone.
}
Enter fullscreen mode Exit fullscreen mode

Lifetimes are often the trickiest part of Rust for newcomers. Yet, their purpose is profoundly practical. They are not a runtime feature; they are compile-time checks that document and enforce how data flows through your program. They turn what would be runtime crashes in other languages into clear compiler errors.

Ownership in Practice: Building Something Real

Let’s apply these concepts to a more realistic scenario. Imagine we’re building part of a simple game server that manages player states. Each player has a name and a score. We want to update a player’s score and maybe broadcast their status to other parts of the program.

First, a version that uses ownership and moves:

struct Player {
    name: String,
    score: u32,
}

fn update_score(mut player: Player, points: u32) -> Player {
    player.score += points;
    println!("{} now has {} points.", player.name, player.score);
    player // Ownership is returned back to the caller.
}

fn main() {
    let player_one = Player {
        name: String::from("Alice"),
        score: 10,
    };

    // `player_one` is moved into the function.
    let player_one = update_score(player_one, 5);
    // We get a new `Player` back and rebind it to the same variable name.

    println!("Back in main: {} has {} points.", player_one.name, player_one.score);
}
Enter fullscreen mode Exit fullscreen mode

This works, but moving the entire Player struct in and out of functions can be inefficient if the struct is large. It’s better to borrow it. Let’s use mutable borrowing:

fn update_score_by_ref(player: &mut Player, points: u32) {
    player.score += points;
    println!("{} now has {} points.", player.name, player.score);
    // No return needed; we modified the original data through the reference.
}

fn broadcast_status(player: &Player) {
    println!("[Broadcast] Player {} is at {} points.", player.name, player.score);
}

fn main() {
    let mut player_one = Player {
        name: String::from("Bob"),
        score: 20,
    };

    // We borrow `player_one` mutably to update it.
    update_score_by_ref(&mut player_one, 5);

    // Now we can borrow it immutably to broadcast.
    broadcast_status(&player_one);

    // We could even borrow immutably multiple times.
    let status_view1 = &player_one;
    let status_view2 = &player_one;
    println!("View1 score: {}, View2 name: {}", status_view1.score, status_view2.name);

    // And then later, borrow mutably again if needed.
    update_score_by_ref(&mut player_one, 10);
}
Enter fullscreen mode Exit fullscreen mode

Notice the flow: we have a mutable borrow, then after it’s done, we have immutable borrows, then a mutable borrow again. The compiler tracks these phases, ensuring they never overlap. This is the essence of safe concurrency. If these were threads, the rules would be the same, preventing data races before you even run the program.

How This Prevents Common Errors

Let’s look at classic C-style errors and see how Rust’s ownership model stops them.

1. Use-After-Free / Dangling Pointer:
In C, you might do this:

char* create_greeting() {
    char buffer[100] = "Hello";
    return buffer; // Returns a pointer to local memory that will be reclaimed.
} // DANGER! `buffer` is destroyed here.

void use_greeting() {
    char* greeting = create_greeting(); // Pointer is now invalid.
    printf("%s\n", greeting); // Undefined behavior!
}
Enter fullscreen mode Exit fullscreen mode

In Rust, you cannot return a reference to something that will die inside the function. You must transfer ownership of the data itself.

fn create_greeting() -> String { // Returns an owned String, not a reference.
    let buffer = String::from("Hello");
    buffer // Ownership is moved out of the function.
}
Enter fullscreen mode Exit fullscreen mode

2. Double Free:
In C, if two parts of the code both think they own the same memory and both call free() on it, the program crashes.

int* ptr = malloc(sizeof(int));
int* ptr2 = ptr;
free(ptr);
free(ptr2); // CRASH! Double free.
Enter fullscreen mode Exit fullscreen mode

In Rust, because ownership moves, there is never ambiguity about who should free the memory. When ptr moves to ptr2, ptr becomes unusable. There is only one owner to drop the value when it goes out of scope.

3. Data Race:
As shown earlier, the borrow checker’s rule—either one mutable reference or any number of immutable references—prevents simultaneous reading and writing. In a multi-threaded context, this same rule, combined with Rust’s concurrency primitives, guarantees thread safety at compile time.

use std::thread;

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

    // This will not compile. The closure might outlive `data`.
    // thread::spawn(|| {
    //     data.push(4); // ERROR: `data` may not live long enough.
    // });

    // The solution is to move ownership of `data` into the thread.
    let handle = thread::spawn(move || {
        let mut inner_data = vec![1,2,3];
        inner_data.push(4);
        inner_data
    });

    // `data` is no longer accessible here; it was moved.
    let result = handle.join().unwrap();
    println!("Result from thread: {:?}", result);
}
Enter fullscreen mode Exit fullscreen mode

The Cost and The Benefit

This system has a cost: a learning curve. You will fight with the borrow checker. Your first Rust programs will spend more time compiling than running as you learn to structure your code within these rules. It can feel like the compiler is being pedantic.

But this is the point. It is forcing you to resolve ambiguities and potential bugs now, at your desk, rather than at 2 a.m. when your service crashes in production. The benefit is a profound sense of confidence. When your Rust program compiles, especially a concurrent one, you have guarantees that are simply unavailable in most other languages. You know there are no null pointer dereferences, no data races, no use-after-free bugs. These aren’t just theoretical promises; they are enforced by the compiler for every single build.

This model also enables zero-cost abstractions. Because the compiler knows exactly when and how data is accessed, it can optimize your code aggressively. There’s no need for a garbage collector to periodically scan memory, leading to predictable performance—a critical feature for operating systems, game engines, browsers, and database systems.

A New Way of Thinking

Working with Rust’s ownership model changed how I think about code in any language. It makes you explicit about the lifecycle and accessibility of data. You start to see resources—memory, files, network connections—as things that have a clear beginning and end, with a single responsible owner at any given time.

The initial frustration gives way to a powerful workflow. You write code, the compiler gives you an error, you think about the true relationship between your data, you fix it, and the code becomes not just more correct, but often cleaner and more understandable. The ownership model isn’t just a safety feature; it’s a design assistant.

It demonstrates that we don’t have to choose between control and safety, or between performance and security. With a well-designed system of rules that are checked automatically, we can have both. The Rust compiler, through its ownership, borrowing, and lifetime checks, acts as a relentless partner, ensuring that the foundation of our programs is solid before we ever ask them to run.

📘 Checkout my latest ebook for free on my channel!

Be sure to like, share, comment, and subscribe to the channel!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)