Rust Learning Roadmap for Beginners

Learn Rust by installing rustup, reading the official book chapters 1-16, and building a CLI tool to practice core concepts.

The wall you hit on day one

You come from Python or JavaScript. You write x = 1. You write x = 2. In Rust, you write let x = 1; let x = 2; and the compiler screams. You try to pass a string to a function and suddenly you've moved the value, leaving the original variable empty. You try to mutate a list while iterating over it and the borrow checker blocks you. You feel like the compiler is fighting you. It is. That's the point.

Rust forces you to think about memory and data flow explicitly. The roadmap below isn't a checklist of syntax to memorize. It's a path to rewiring your brain so the compiler stops yelling and starts helping. You will hit walls. You will stare at error messages for an hour. That process is where the learning happens. The goal isn't to write code that compiles. The goal is to build mental models that make the compiler's rules feel natural.

Stop treating the compiler as an enemy. Treat it as a strict pair programmer who knows exactly where your bugs are hiding.

Phase 1: Tooling and the first mental shift

Install Rust with rustup. Never use your operating system's package manager. apt install rustc gives you a stale version that lags months behind the ecosystem. rustup is the official installer and version manager. It lets you switch between stable, beta, and nightly toolchains. It installs components like rust-src for better IDE support and clippy for linting. It keeps your environment sane.

# Install rustup. This downloads the latest stable toolchain.
# The script is interactive; press Enter to accept defaults.
curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh

Once installed, use cargo for everything. cargo is the build tool, package manager, test runner, and documentation generator. It creates projects, manages dependencies, and runs your code. The community convention is to never invoke rustc directly. cargo handles compilation flags, caching, and dependency resolution.

# Create a new binary project. Cargo generates the directory structure.
# It adds a Cargo.toml for dependencies and a src/main.rs for code.
cargo new minigrep

# Run the project. Cargo compiles and executes in one step.
cargo run

The first mental shift is immutability by default. In Python, variables are mutable unless you do something special. In Rust, bindings are immutable unless you write mut. This isn't a restriction. It's a safety net. If you don't mark a variable as mutable, the compiler prevents accidental state changes. It makes your intent explicit.

Write let mut every time you need mutation. The compiler will remind you if you forgot.

Phase 2: Ownership and the borrow checker

Every value in Rust has exactly one owner. When the owner goes out of scope, the value is dropped. There is no garbage collector. There is no manual free. This rule eliminates double-free bugs and use-after-free errors at compile time.

When you assign a value to a new variable, the ownership moves. The original variable becomes invalid. This is called a move.

fn main() {
    // s1 owns a String on the heap.
    let s1 = String::from("hello");

    // s2 takes ownership. s1 is no longer valid.
    let s2 = s1;

    // This line causes a compile error.
    // s1 was moved to s2, so it holds no data.
    // The compiler rejects this with E0382 (use of moved value).
    // println!("{}", s1);
}

Moves prevent two variables from trying to free the same memory. If you need to share data without moving it, use references. A reference borrows the value without taking ownership. You can have many immutable references &T, or exactly one mutable reference &mut T. You cannot have both at the same time.

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

    // r1 and r2 are immutable borrows.
    // They can coexist because neither modifies the data.
    let r1 = &s;
    let r2 = &s;
    println!("{}, {}", r1, r2);

    // r1 and r2 are no longer used after this line.
    // The borrow checker tracks usage, not just declaration.
    // This allows r3 to exist safely.

    // r3 is a mutable borrow.
    // It requires exclusive access.
    let r3 = &mut s;
    r3.push_str(", world");
}

If you try to create a mutable borrow while an immutable borrow is still active, the compiler rejects you with E0502 (cannot borrow as mutable because it is also borrowed as immutable). This rule prevents data races. If you try to mutate a variable declared without mut, you get E0596 (cannot borrow as mutable, as it is not declared mutable).

Trust the borrow checker. It usually has a point. If you're fighting it, you're probably trying to do something unsafe or you need to restructure your data.

Phase 3: Structs, enums, and pattern matching

Rust uses structs for records and enums for variants. Structs group related data. Enums represent a value that can be one of several variants. Enums in Rust are powerful. They can hold data. They're not just C-style integers.

// Shape can be a Circle or a Rectangle.
// Each variant carries its own data.
enum Shape {
    Circle { radius: f64 },
    Rectangle { width: f64, height: f64 },
}

// match is exhaustive. You must handle every variant.
// This prevents forgotten cases at runtime.
fn area(shape: &Shape) -> f64 {
    match shape {
        Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
        Shape::Rectangle { width, height } => width * height,
    }
}

The standard library uses enums extensively. Option<T> represents a value that might be present or absent. Result<T, E> represents a value that might be an error. These types force you to handle absence and errors explicitly. You can't ignore them.

Use match when you need to handle all cases. Use if let when you only care about one case and want to ignore the rest. if let is syntactic sugar for a match with one arm and a wildcard for the rest.

fn main() {
    let maybe_number = Some(42);

    // if let extracts the value if it matches Some.
    // It ignores None silently.
    if let Some(n) = maybe_number {
        println!("The number is {}", n);
    }
}

Use enums to encode business rules into the type system. If a state can't exist, make it impossible to represent.

Phase 4: Error handling without exceptions

Rust has no exceptions. Errors are values. Functions return Result<T, E> when they can fail. The ? operator propagates errors up the call stack. It makes error handling concise and readable.

use std::fs::File;
use std::io::Read;

// This function returns a Result.
// Callers must handle the error or propagate it.
fn read_username_from_file() -> Result<String, std::io::Error> {
    // File::open returns Result<File, Error>.
    // The ? operator returns early if open fails.
    // It unwraps the File if open succeeds.
    let mut file = File::open("hello.txt")?;

    let mut contents = String::new();
    // read_to_string returns Result<usize, Error>.
    // ? propagates the error if reading fails.
    file.read_to_string(&mut contents)?;

    // Ok wraps the success value.
    Ok(contents)
}

When you call a function that returns Result, you must handle the error. You can use ? to propagate it. You can use match to handle it locally. You can use unwrap() to panic if the error occurs. You can use expect() to panic with a custom message.

The community convention is to avoid unwrap() in library code. unwrap() panics with a generic message. expect() panics with a message you provide. Use expect with a clear description of why the error is unexpected. In quick scripts or tests where failure is impossible, unwrap is acceptable.

Never call unwrap() in production code unless you can prove the error is impossible. Write the error handler, or use ?.

Phase 5: Traits and generics

Traits define shared behavior. They're like interfaces in other languages, but with more power. Generics allow you to write code that works with multiple types. Trait bounds constrain generics to types that implement a trait.

// Summary defines a method that any type can implement.
trait Summary {
    fn summarize(&self) -> String;
}

// Article implements Summary.
struct Article {
    title: String,
    content: String,
}

impl Summary for Article {
    fn summarize(&self) -> String {
        // Return a short preview of the article.
        format!("{}: {}", self.title, &self.content[..10])
    }
}

// notify accepts any type that implements Summary.
// The compiler generates specialized code for each type.
// This is zero-cost abstraction.
fn notify(item: &impl Summary) {
    println!("Breaking news! {}", item.summarize());
}

Generics use monomorphization. The compiler generates a separate copy of the function for each type you use. This avoids runtime dispatch overhead. You get the flexibility of generics with the performance of specialized code.

Reach for traits when you need polymorphism. Use generics when the logic is identical across types.

Phase 6: Build a real project

Theory isn't enough. Build something. The official book suggests minigrep, a simplified version of the grep command. It reads a file, searches for a pattern, and prints matching lines. It teaches file I/O, command-line arguments, error handling, and testing.

// src/main.rs
use std::env;
use std::fs;

fn main() {
    // Parse command-line arguments.
    let args: Vec<String> = env::args().collect();

    // args[0] is the program name.
    // args[1] is the pattern.
    // args[2] is the filename.
    let query = &args[1];
    let file_path = &args[2];

    // Read the file contents.
    let contents = fs::read_to_string(file_path)
        .expect("Should have been able to read the file");

    // Search and print results.
    for line in search(query, &contents) {
        println!("{}", line);
    }
}

fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    contents
        .lines()
        .filter(|line| line.contains(query))
        .collect()
}

Write tests. cargo test runs your test suite. Unit tests live in the same file as the code, inside a #[cfg(test)] module. Integration tests live in the tests/ directory. They test the public API as external users would use it.

// src/lib.rs
pub fn add_two(a: usize) -> usize {
    a + 2
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add_two(2);
        assert_eq!(result, 4);
    }
}

Build something that breaks. Fix it. Repeat. The compiler errors are your teacher.

Pitfalls and compiler errors

You will encounter these errors often. Learn to read them.

  • E0382 (use of moved value): You tried to use a value after moving it. Fix: Clone the value or borrow it with &.
  • E0502 (cannot borrow as mutable because it is also borrowed as immutable): You have an active immutable borrow and tried to create a mutable borrow. Fix: End the immutable borrow before mutating, or restructure your code to avoid overlapping borrows.
  • E0596 (cannot borrow as mutable, as it is not declared mutable): You tried to mutate a variable without mut. Fix: Add mut to the binding.
  • E0277 (trait bound not satisfied): You used a type that doesn't implement a required trait. Fix: Implement the trait or add a trait bound to your generic.
  • E0308 (mismatched types): You passed a value of the wrong type. Fix: Check the function signature and convert the value if needed.

Read the error message carefully. Rust errors often suggest the fix. Follow the suggestion. If it doesn't make sense, look at the code it points to. The compiler is usually right.

Decision matrix

Use rustup when you need to install Rust, switch versions, or manage toolchain components. Use cargo when you need to create projects, manage dependencies, run code, or run tests. Use &T when you need to read data without taking ownership. Use &mut T when you need to modify data and have exclusive access. Use Result<T, E> when a function can fail and the caller should handle the error. Use Option<T> when a value might be absent. Use match when you need to handle all variants of an enum. Use if let when you only care about one variant and want to ignore the rest. Use impl Trait when you want to accept any type that implements a trait without naming the generic. Use generics with trait bounds when you need to reuse logic across multiple types. Use expect with a clear message instead of unwrap in library code. Use cargo test to run your test suite. Use clippy to catch common mistakes and improve your code.

Build something that breaks. Fix it. Repeat. The compiler errors are your teacher.

Where to go next