Skip to main content

Core Concepts of Rust

Published: April 24, 2026 Updated: August 28, 2026 Larry Qu 9 min read

Rust’s biggest promise is memory safety without garbage collection. The compiler enforces memory rules at compile time through a system of ownership and borrowing. This guide explains those rules — not just the syntax, but the mental models that make them click.

Why Rust Feels Different

In C and C++, the programmer manually manages memory — allocate, use, free. Forget to free: memory leak. Free twice: crash. Use after free: security vulnerability.

In Java and Python, a garbage collector handles memory automatically — at the cost of runtime overhead and unpredictable pauses.

Rust takes a third path: ownership rules baked into the type system. The compiler verifies at compile time that memory is always valid and never double-freed. No runtime overhead, no GC pauses, no dangling pointers.

Ownership

Every value in Rust has exactly one owner. When the owner goes out of scope, the value is dropped (memory freed):

fn main() {
    let s = String::from("hello"); // s owns the String

    // s goes out of scope here → String is freed
}

Ownership can be transferred (moved):

fn takes_ownership(s: String) {
    println!("{}", s);
} // s is dropped here

fn main() {
    let name = String::from("Rust");
    takes_ownership(name);
    // println!("{}", name); // ERROR: name was moved, no longer valid
}

For types that implement Copy (integers, floats, booleans, chars, and tuples of them), assignment copies the value instead of moving it:

fn main() {
    let x = 5;
    let y = x; // x is copied — both are valid
    println!("x={}, y={}", x, y);

    let s1 = String::from("hello");
    let s2 = s1; // s1 is MOVED — not copied
    // println!("{}", s1); // ERROR
    println!("{}", s2);
}

Clone: Explicit Deep Copy

When you genuinely need a copy of heap data, call .clone():

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone(); // Deep copy — allocates new heap memory
    println!("s1={}, s2={}", s1, s2); // Both valid
}

Use clone deliberately — it’s an allocation. If you find yourself cloning frequently, reconsider whether borrowing would work instead.

Borrowing

Instead of transferring ownership, you can lend a reference — a borrow:

fn print_string(s: &str) { // borrows s, doesn't take ownership
    println!("{}", s);
}

fn main() {
    let name = String::from("Alice");
    print_string(&name); // lend a reference
    println!("still own: {}", name); // still valid
}

The borrow checker enforces two rules simultaneously:

  1. You can have any number of immutable borrows (&T)
  2. OR exactly one mutable borrow (&mut T)
  3. Never both at the same time
fn main() {
    let mut s = String::from("hello");

    // Multiple immutable borrows — OK
    let r1 = &s;
    let r2 = &s;
    println!("{} and {}", r1, r2); // Last use of r1, r2

    // Now a mutable borrow is allowed (r1 and r2 are no longer used)
    let r3 = &mut s;
    r3.push_str(", world");
    println!("{}", r3);
}

The rule prevents data races: if you can only mutate through one reference at a time, two threads can never race on the same data.

The Borrow Checker in Practice

The classic “fight with the borrow checker” usually means your code has an ownership design issue. Common patterns to restructure:

// BAD: holding reference while also mutating
fn main() {
    let mut v = vec![1, 2, 3];
    let first = &v[0]; // immutable borrow
    v.push(4);         // ERROR: mutable borrow while immutable exists
    println!("{}", first);
}

// GOOD: copy the value first
fn main() {
    let mut v = vec![1, 2, 3];
    let first = v[0]; // copy the i32 (it's Copy)
    v.push(4);        // OK — no active borrow
    println!("{}", first);
}

Lifetimes

Lifetimes describe how long references are valid. The compiler infers them in most cases, but some function signatures need explicit annotations:

// Without lifetime annotation — compiler rejects this
// (can't determine which input the output borrows from)
fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() { x } else { y }
}

// With lifetime annotation — tells compiler output lives
// at most as long as both inputs
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

fn main() {
    let s1 = String::from("long string is long");
    let result;
    {
        let s2 = String::from("xyz");
        result = longest(s1.as_str(), s2.as_str());
        println!("{}", result); // Used here — s2 still alive
    }
}

Lifetime Elision Rules

The compiler applies three rules automatically to infer lifetimes:

  1. Each reference parameter gets its own lifetime
  2. If there’s exactly one input lifetime, it’s assigned to all outputs
  3. If one of the inputs is &self or &mut self, the self lifetime is assigned to output

This is why most functions don’t need explicit lifetime annotations.

Lifetimes in Structs

When a struct holds a reference, it needs a lifetime annotation:

#[derive(Debug)]
struct Excerpt<'a> {
    text: &'a str, // struct can't outlive the str it borrows
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();

    let excerpt = Excerpt { text: first_sentence };
    println!("{:?}", excerpt); // Excerpt { text: "Call me Ishmael" }

    // excerpt cannot outlive `novel` — compiler enforces this
}

Structs and Data Modeling

Structs group related data:

#[derive(Debug, Clone)]
struct User {
    username: String,
    email: String,
    active: bool,
    login_count: u32,
}

impl User {
    // Associated function (constructor pattern)
    fn new(username: &str, email: &str) -> Self {
        User {
            username: username.to_string(),
            email: email.to_string(),
            active: true,
            login_count: 0,
        }
    }

    fn login(&mut self) {
        self.login_count += 1;
    }

    fn deactivate(&mut self) {
        self.active = false;
    }
}

fn main() {
    let mut user = User::new("alice", "[email protected]");
    user.login();
    user.login();

    println!("{}: {} logins, active={}", user.username, user.login_count, user.active);

    // Struct update syntax — copy remaining fields from another instance
    let user2 = User {
        email: "[email protected]".to_string(),
        username: "bob".to_string(),
        ..user  // copy active and login_count from user
    };
    println!("{:?}", user2);
}

Enums for Modeling Variants

Enums define types that can be one of several variants, each optionally holding different data:

#[derive(Debug)]
enum NetworkEvent {
    Connected { host: String, port: u16 },
    Disconnected,
    MessageReceived(String),
    Error { code: u32, message: String },
}

impl NetworkEvent {
    fn is_error(&self) -> bool {
        matches!(self, NetworkEvent::Error { .. })
    }
}

fn handle(event: NetworkEvent) {
    match event {
        NetworkEvent::Connected { host, port } => {
            println!("Connected to {}:{}", host, port);
        }
        NetworkEvent::Disconnected => {
            println!("Disconnected");
        }
        NetworkEvent::MessageReceived(msg) => {
            println!("Got: {}", msg);
        }
        NetworkEvent::Error { code, message } => {
            eprintln!("Error {}: {}", code, message);
        }
    }
}

match is exhaustive — the compiler forces you to handle every variant. This prevents unhandled cases from becoming runtime bugs.

Error Handling: Result and Option

Rust has no null and no exceptions. Absence and failure are explicit types:

use std::num::ParseIntError;
use std::fs;

// Option<T> — value may or may not exist
fn find_first_even(v: &[i32]) -> Option<i32> {
    v.iter().copied().find(|&x| x % 2 == 0)
}

// Result<T, E> — operation may succeed or fail
fn parse_port(s: &str) -> Result<u16, ParseIntError> {
    s.parse::<u16>()
}

// The ? operator propagates errors early
fn read_and_parse(path: &str) -> Result<u16, Box<dyn std::error::Error>> {
    let content = fs::read_to_string(path)?;    // ? returns if Err
    let port = content.trim().parse::<u16>()?;  // ? returns if Err
    Ok(port)
}

fn main() {
    // Option
    let nums = vec![1, 3, 5, 8, 9];
    match find_first_even(&nums) {
        Some(n) => println!("First even: {}", n),
        None    => println!("No even numbers"),
    }

    // Concise Option methods
    let default_port = find_first_even(&[1, 3, 5]).unwrap_or(8080) as u16;
    println!("Port: {}", default_port);

    // Result
    match parse_port("8080") {
        Ok(port) => println!("Valid port: {}", port),
        Err(e)   => println!("Invalid: {}", e),
    }

    // Chain operations on Result
    let doubled = parse_port("21").map(|p| p * 2);
    println!("{:?}", doubled); // Ok(42)
}

Don’t Use unwrap() in Production

unwrap() and expect() panic on None/Err. Use them in tests and prototyping, but production code should propagate or handle errors:

// Testing/prototyping — ok
let x: i32 = "42".parse().unwrap();

// Production — handle the error
let x: i32 = "42".parse().unwrap_or(0);
let x: i32 = "42".parse().unwrap_or_else(|_| {
    eprintln!("Parse failed, using default");
    0
});

The Stack vs Heap Mental Model

Understanding where values live helps explain Rust’s rules:

Stack (fast, fixed size, auto-freed on scope exit):
- i32, f64, bool, char
- &T, &mut T (the pointer itself)
- fixed-size arrays [T; N]
- structs of stack types

Heap (slower, dynamic size, freed when owner drops):
- String (Vec<u8> internally)
- Vec<T>
- Box<T>
- Any type with dynamic size

When a String owner goes out of scope, Rust calls drop(), which frees the heap allocation. This is deterministic — no GC needed.

Common Beginner Pitfalls

1. Overusing clone

// Bad: clone to avoid borrow checker
fn get_name(user: &User) -> String { user.username.clone() }

// Better: return reference if you can
fn get_name(user: &User) -> &str { &user.username }

2. Fighting the borrow checker instead of redesigning

If you’re stuck, ask: “who should own this data?” Often the answer is to restructure so the data lives in one place and others borrow it.

3. Returning references to local variables

fn broken() -> &str {
    let s = String::from("hello");
    &s  // ERROR: s is dropped at end of function, reference would dangle
}

// Fix: return owned String
fn fixed() -> String {
    String::from("hello")
}

4. Forgetting that iterating consumes by default

let v = vec![1, 2, 3];
for x in v { println!("{}", x); } // v is consumed (into_iter())
// println!("{:?}", v); // ERROR

// If you need v afterward, iterate by reference
for x in &v { println!("{}", x); }
println!("{:?}", v); // still valid

Practical Learning Path

  1. Ownership + move semantics — understand why values can only be used once after moving
  2. Borrowing + borrow checker — immutable vs mutable, why you can’t do both simultaneously
  3. Structs + enums — building your own types
  4. Option + Result + match — Rust’s error handling philosophy
  5. Traits — shared behavior, generics, polymorphism
  6. Lifetimes — usually elided; learn when you need to annotate
  7. Closures + iterators — functional patterns, zero-cost abstractions
  8. ConcurrencySend, Sync, Arc, Mutex

Don’t try to learn everything at once. The borrow checker becomes intuitive after you’ve refactored a few programs that don’t compile.

Summary

The three rules that underpin Rust’s memory model:

  1. Every value has exactly one owner
  2. When the owner goes out of scope, the value is dropped
  3. References must always be valid — you can have many &T OR one &mut T, never both

These rules, enforced at compile time, eliminate:

  • Use-after-free bugs
  • Double-free bugs
  • Null pointer dereferences
  • Data races in concurrent code

The cost: a learning curve. The payoff: programs that are correct about memory by construction.

Resources

Comments

👍 Was this article helpful?