Ownership and Borrowing in Rust

December 1, 2025

Ownership in rust is an amazing feature, it enables memory safety and it does it without the need of garbage collector. Ownership is mostly a way to manage memory. Other programming language can ensure memory management by using garbage collection.

In high level programming language like java or C#, we don't really need to worry about memory management as garbage collector does it for us.

Let's try to understand the pros and cons of garbage collector.

Pros:

Cons:

Let's look at the manual Memory Management. In C/C++ you have to manually allocate or deallocate memory.

Pro:

Cons:

But ownership model in rust is way different than all of these.

Pro:

Con:


In rust, we have some strict set of rules for memory management and if you break any ofthose then you'll get compile time error.

Stack is fixed size so it can't grow or shrink during runtime but heap can. During runtime a program has access to stack and heap both. Stack also creates stack frames which created for every function that executes.

Stack frame normally stores local variables of the function being executed. The size of the stack frame normally gets calculated during compile time. Which also means the variable inside stack frames must have knwon fixed size. And variable inside the stack frames lives as long as the stack frame lives.

Stack and Heap

Let's talk about heap. Heap can grow and shrink at runtime. Data stored in heap could be synamic in size and we control the lifetime of the data.

fn main () {
    fn a () {
        let x: &str = "hello";
        let y:i32 = 22;
        b();
    }
    fn b() {
        let x:String::from("world");
    }
}

Let's look at this example, In this program, a gets executed first, so we push it onto the stack. Then a also executes b so we also push stack frame for b into the stack.

When b finishes executing, it gets popped off the stack and all of it's variables gets dropped. Same with a , when it gets done executed, it's local variables also gets dropped.

Now let's talk about heap for this program, over here after executing function a, it creates new stack frames and initializes the variables x and y.
Here x is a string literal, which is actually stored in our binary. So in the stack frame, x will be a reference to that string in binary. But y is a i32 bit integer which is a fixed size, so we can store y directly in the stack frame.

When we execute function b, it also creates another stack frame. then b also creates it's own variable named x which is a string type.
As x is string, dynamic in size, we can't really store it directly in stack, so we ask heap to allocate memeory for the string, which heap does.

One thing to note is that pushing to stack is faster than allocating on the heap because the heap has to has to spend time looking for a place to store new data. Accessing data on the stack is faster than heap,because with heap you just need to follow the pointer.

Let's understand some ownership rules:


Normally String literals are directly stored in binary and are fixed in size so stored on stack. But what if we wanted a string dynamic and we can mutate?

let s: String::from ("hello");

Now it will be stored on heap. Let's look into it, so when we declared this string, rust already allocates memory on heap. When scope ends, rust drops our value and de-allocates memory on heap.

How variables and data interact?

let x: i32 = 5
let y: i32 = x; //copy

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

Here to ensure memory safety, rust invalidates s1, so it moves the value.

image.png

Here to ensure meory safety, rust invalidates s1. So it moves the value.

You can also clone let s2: String = s1.clone().

Rust mostly defaults to move but if you want to do more expensive clone then clone().

Rust has copy trait for simple types, stored on stack, such as integers, booleans and characters.

This copy trait allows these types to be copied than move.

If you want to learn more about ownership, head over here.


What if we want to use a variable without taking ownership?

That's where references comes in.

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

    let len = calculate_length(&s1);

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

fn calculate_length(s: &String) -> usize {
    s.len()
}

Note that we pass &s1 into calculate_length and, in its definition, we take &String rather than String. These ampersands represent references, and they allow you to refer to some value without taking ownership of it

References don't take ownership of the value. For example &String is a reference type. References are immutable by default. We have to make the variable mutable to make the reference mutable.

Some rules of reference:

Mutable References

We mentioned references are immutable by default. But what if we want to modify borrowed data? We need mutable references.

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

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

Notice three things:

All three are required for this to work.

The Big Restriction: You can only have ONE mutable reference to a value in a particular scope. This prevents data races at compile time.

let mut s = String::from("hello");

let r1 = &mut s;
let r2 = &mut s; // ERROR: cannot borrow `s` as mutable more than once

println!("{r1}, {r2}");

You also can't mix mutable and immutable references:

let mut s = String::from("hello");

let r1 = &s;     // immutable borrow
let r2 = &s;     // fine, multiple immutable borrows allowed
let r3 = &mut s; // ERROR: cannot borrow as mutable because it's also borrowed as immutable

println!("{r1}, {r2}, {r3}");

Dangling References

Rust prevents dangling references (pointers to memory that has been freed). The compiler ensures references are always valid.

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

fn dangle() -> &String {  // ERROR!
    let s = String::from("hello");
    &s  // we return a reference to s
}   // s goes out of scope and is dropped. Its memory is gone!

The compiler will stop you:

error[E0106]: missing lifetime specifier

The fix? Return the owned value instead:

fn no_dangle() -> String {
    let s = String::from("hello");
    s  // ownership is moved out, nothing is dropped
}

Ownership and Functions

Passing a value to a function moves or copies it, just like assignment.

fn main() {
    let s = String::from("hello");  // s comes into scope
    
    takes_ownership(s);             // s's value moves into the function
    
    // println!("{s}");             // ERROR: s is no longer valid here
    
    let x = 5;                      // x comes into scope
    
    makes_copy(x);                  // i32 is Copy, so x is still valid
    
    println!("{x}");                // This works fine!
}

fn takes_ownership(some_string: String) {
    println!("{some_string}");
}   // some_string goes out of scope and `drop` is called. Memory freed.

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

Returning Ownership

Functions can also transfer ownership back:

fn main() {
    let s1 = gives_ownership();         // moves return value into s1
    
    let s2 = String::from("hello");     // s2 comes into scope
    
    let s3 = takes_and_gives_back(s2);  // s2 is moved into function,
                                        // return value moves into s3
}

fn gives_ownership() -> String {
    let some_string = String::from("yours");
    some_string  // returned and moves out to calling function
}

fn takes_and_gives_back(a_string: String) -> String {
    a_string  // returned and moves out to calling function
}

This pattern of taking and returning ownership is tedious. That's exactly why Rust has references.

Slice Types

Slices let you reference a contiguous sequence of elements rather than the whole collection. A slice is a kind of reference, so it doesn't have ownership.

String Slices

let s = String::from("hello world");

let hello = &s[0..5];   // "hello"
let world = &s[6..11];  // "world"

The type of a string slice is &str.

Range syntax shortcuts:

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

let slice = &s[0..2];   // "he"
let slice = &s[..2];    // same thing, start from 0

let len = s.len();
let slice = &s[3..len]; // "lo"  
let slice = &s[3..];    // same thing, go to end

let slice = &s[..];     // entire string

Why Slices Matter

Consider this function that finds the first word:

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();
    
    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }
    
    s.len()
}

This returns an index, but there's a problem:

fn main() {
    let mut s = String::from("hello world");
    
    let word = first_word(&s); // word = 5
    
    s.clear(); // empties the String
    
    // word is still 5, but s is now ""!
    // word is now meaningless and out of sync
}

The index word has no connection to s. With slices, the compiler protects us:

fn first_word(s: &String) -> &str {
    let bytes = s.as_bytes();
    
    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }
    
    &s[..]
}

fn main() {
    let mut s = String::from("hello world");
    
    let word = first_word(&s);  // immutable borrow here
    
    s.clear();  // ERROR: cannot borrow `s` as mutable because 
                // it's also borrowed as immutable
    
    println!("the first word is: {word}");
}

The compiler ensures our slice reference stays valid!

String Literals Are Slices

Remember string literals? They're actually slices:

let s: &str = "Hello, world!";

s is a &str pointing to that specific point in the binary. This is why string literals are immutable—&str is an immutable reference.

Pro tip: Write functions to take &str instead of &String for flexibility:

fn first_word(s: &str) -> &str {  // accepts both &String and &str
    // ...
}

fn main() {
    let my_string = String::from("hello world");
    let word = first_word(&my_string[..]);  // pass slice of String
    
    let my_literal = "hello world";
    let word = first_word(my_literal);  // works directly!
}

Array Slices

Slices work on arrays too:

let a = [1, 2, 3, 4, 5];

let slice = &a[1..3];  // type is &[i32], value is [2, 3]

assert_eq!(slice, &[2, 3]);

This works the same way, slice is a reference to a portion of the array.


Ownership & Borrowing Exercises to Practice


Exercise 1: Move Semantics

Create two String variables where the second is assigned from the first:

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

Try to print both. What happens? Fix it using .clone().


Exercise 2: Copy vs Move

Run this code:

let x: i32 = 5;
let y = x;

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

println!("{}", x);
println!("{}", s1);

Which line causes an error? Why does x work but s1 doesn't?


Exercise 3: Ownership and Functions

This code doesn't compile:

fn main() {
    let message = String::from("hello");
    print_message(message);
    print_message(message);
}

fn print_message(msg: String) {
    println!("{}", msg);
}

Why? Fix it two ways:

  1. Using .clone()
  2. By changing the function to take a reference

Exercise 4: Returning Ownership

Write a function create_greeting(name: &str) -> String that returns "Hello, {name}!".

Call it and print the result. Who owns the returned String — the function or the caller?


Exercise 5: References

Write a function count_words(text: &String) -> usize that returns how many words are in the text (split by whitespace).

Call it twice on the same string:

let text = String::from("the quick brown fox");
println!("{}", count_words(&text));
println!("{}", count_words(&text));  // should work — why?

Hint: Use .split_whitespace().count()


Exercise 6: Mutable References

Write a function add_exclamation(text: &mut String) that appends "!" to the string.

Test it:

let mut msg = String::from("Hello");
add_exclamation(&mut msg);
println!("{}", msg);  // should print "Hello!"

What three things must be mut for this to work?


Exercise 7: Borrowing Rules

This code breaks a borrowing rule:

let mut s = String::from("hello");

let r1 = &s;
let r2 = &s;
let r3 = &mut s;

println!("{}, {}, {}", r1, r2, r3);

What rule does it break? Fix it by rearranging (not removing) lines.

Hint: References are only active until their last use.


Exercise 8: Mutable Reference Restriction

This also breaks a rule:

let mut s = String::from("hello");

let r1 = &mut s;
let r2 = &mut s;

println!("{}, {}", r1, r2);

What's the rule? Fix it so both mutations can happen (sequentially, not simultaneously).


Exercise 9: Dangling Reference

This function won't compile:

fn create_string() -> &String {
    let s = String::from("hello");
    &s
}

Why? What happens to s when the function ends?

Fix it by returning an owned String instead of a reference.


Exercise 10: String Slices

Create a String and practice slice syntax:

let s = String::from("hello world");

// Create slices for:
// 1. "hello" (first 5 characters)
// 2. "world" (last 5 characters)
// 3. "llo wo" (middle portion)
// 4. The entire string using [..]

What is the type of each slice?


Exercise 11: Why Slices Matter

This compiles but has a bug:

fn first_word_index(s: &String) -> usize {
    for (i, &byte) in s.as_bytes().iter().enumerate() {
        if byte == b' ' {
            return i;
        }
    }
    s.len()
}

fn main() {
    let mut s = String::from("hello world");
    let word_end = first_word_index(&s);
    s.clear();
    println!("First word ends at: {}", word_end);  // word_end is now meaningless!
}

Rewrite first_word_index to return &str instead of usize. Name it first_word.

After your change, what happens when you try to call s.clear() before using the slice?


Exercise 12: Flexible Function Parameters

Write a function is_empty_or_whitespace(s: &str) -> bool that returns true if the string is empty or contains only spaces.

Test it with both a String and a string literal:

let owned = String::from("   ");
let literal = "hello";

println!("{}", is_empty_or_whitespace(&owned));    // true
println!("{}", is_empty_or_whitespace(literal));   // false

Why is &str more flexible than &String for the parameter type?

Hint: Use .trim().is_empty()