Overview

I put off learning Rust for two years because every introduction led with "Rust has no garbage collector and enforces memory safety at compile time" and then dove into &mut versus & versus bare values with no explanation of why the compiler is so upset.

Coming from Python, the actual mental shift is smaller than it looks. Python has references to everything; you just don't think about them because the interpreter cleans up. Rust makes you think about them, in exchange for doing the cleanup at compile time.

This is the model that finally made it click for me.

The one rule

Every value in Rust has exactly one owner. When the owner goes out of scope, the value is dropped. That's it. Everything else follows from this rule.

fn main() {
    let s = String::from("hello");
    println!("{}", s);
}   // s goes out of scope here, memory is freed

In Python, s is freed when the reference count hits zero, which might be later. In Rust, s is freed at the closing brace, deterministically. No GC, no reference counting overhead, no cleanup thread.

Move semantics

Assigning a value transfers ownership. The old variable can no longer be used.

let s1 = String::from("hello");
let s2 = s1;
println!("{}", s1);   // compile error: value borrowed after move

Coming from Python, this feels hostile. In Python, s2 = s1 makes both names point at the same string object and both remain usable. Rust does the same thing under the hood — s1 and s2 point at the same heap data — but then refuses to let you use s1 because it knows that when both go out of scope, the memory would be freed twice.

The compiler is preventing the double-free, and the way it does that is by making ownership explicit. Once you see it as "the compiler is protecting me from a bug I'd hit in C," it stops feeling punitive.

For simple types with a fixed size on the stack — integers, booleans, floats — Rust copies instead of moves, because there's no heap pointer involved. let x = 5; let y = x; leaves both usable. That's why some examples seem inconsistent at first: String moves, i32 copies.

Borrowing

Moving every value around would be unusable. That's what references are for.

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

let s = String::from("hello");
let len = calculate_length(&s);
println!("{} has length {}", s, len);   // s is still usable

&s creates a reference. The function borrows the string, uses it, and returns. Ownership stays with the caller. The reference is valid only for the duration of the call.

References can be either immutable (&T) or mutable (&mut T). And this is where the rules people complain about actually live.

The borrowing rules

At any point in time, a value can have either:

  • Any number of immutable references, or
  • Exactly one mutable reference

Never both. This is the rule that the compiler enforces, and the rule that people rage against before they understand it.

Why it exists: if you have a mutable reference that could change the value while another piece of code holds an immutable reference, that other code might read half-updated state. In single-threaded code, this is a subtle bug. In multi-threaded code, it's a data race. Rust prevents both with the same rule.

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

let r1 = &s;
let r2 = &s;
println!("{} {}", r1, r2);   // fine: multiple immutable refs

let r3 = &mut s;             // error: cannot borrow as mutable while immutably borrowed

The classic example that trips people up:

let mut v = vec![1, 2, 3];

for item in &v {
    v.push(*item * 2);   // error: cannot borrow v as mutable
}

This feels like it should work. The fix is to collect the new values and extend after the loop, or iterate by index. And the reason the compiler refuses is that v.push might reallocate the vector's buffer, invalidating the iterator that's holding a pointer into the old buffer. In C, this is a use-after-free waiting to happen. In Rust, it's a compile error.

The compiler is verbose but helpful

Rust's error messages are genuinely good:

error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
 --> src/main.rs:4:9
  |
3 |     for item in &v {
  |                 -- immutable borrow occurs here
4 |         v.push(*item * 2);
  |         ^^^^^^^^^^^^^^^^^ mutable borrow occurs here
5 |     }
  |     - immutable borrow later used here

It tells you exactly what's wrong and where. The frustration when learning Rust is not "the compiler is unhelpful," it's "the compiler is right and my mental model was wrong."

Ownership across function boundaries

The pattern that took me longest to internalize: a function that takes ownership also takes responsibility for freeing. So most functions take references.

// Takes ownership — caller cannot use the string afterwards
fn consume(s: String) -> usize {
    s.len()
}

// Borrows — caller keeps ownership
fn measure(s: &String) -> usize {
    s.len()
}

let owned = String::from("hi");
measure(&owned);       // fine, owned still valid
consume(owned);        // moves it
// println!("{}", owned);  // error from here on

Rule of thumb: take &str instead of &String when you only need to read, take &T when you need a reference, and take T by value only when you're going to store it or transform it into something else.

Lifetimes, briefly

Lifetimes are the compiler's way of tracking how long a reference is valid. Most of the time, you don't write them — the compiler infers them. When it can't, you get an error and have to annotate:

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

The 'a says "the returned reference is valid for as long as both inputs." Without it, the compiler doesn't know which input the return value borrows from, and refuses to compile.

You can get a long way in Rust without writing a single lifetime annotation. When you do need one, it's usually because a function returns a reference derived from its arguments, which is uncommon in application code.

Comparing to Python

PythonRust
Memory managementReference counting + GCCompile-time ownership
b = aBoth usable, shared referenceMoved for heap types, copied for stack types
Passing to a functionShared reference, alwaysMove or borrow, chosen by the signature
Thread safetyGIL prevents data racesBorrow rules prevent data races at compile time
Runtime costGC pausesNone
Compile-time costMinimalHigher — fighting the borrow checker is a phase

The GIL line is worth pausing on. Python's GIL means you can freely share data between threads because only one thread runs at a time — which is also why Python threading doesn't scale CPU-bound work. Rust has no GIL because it doesn't need one: the borrow checker guarantees that shared mutable state is impossible.

Getting past the initial hump

Four things that made the learning curve shorter for me:

  • Read the errors, actually read them. They explain the problem and usually suggest a fix. Skipping straight to Stack Overflow wastes the best learning material the language gives you.
  • Clone first, optimize later. If you're stuck on a borrow issue and don't know why, .clone() and move on. Performance can wait until the code works.
  • Write small programs. A game of "does this compile" with thirty-line examples teaches more than trying to port a Python project immediately.
  • Come back the next day. The borrow checker makes sense in a way that consolidates overnight. Fighting it for four hours is less productive than fighting it for one, walking away, and coming back.

The payoff is real: no runtime surprises from memory, no GIL, no GC pauses, and code that scales to threads without a rewrite. The cost is a few days of frustration while your intuition catches up to the compiler's.