Skip to content
rusthacks.dev
Rust

A Learning Path for Rust That Skips the Common Dead Ends

Most people stall in Rust at the same three places. This path orders the topics so each one rests on the last, and tells you what to skip until you need it.

16 min read
Summarize in AI
Flat illustration for the post “A Learning Path for Rust That Skips the Common Dead Ends”, composed around ownership.

Rust has a reputation for a steep start, and most of the steepness is a matter of order. People meet lifetime annotations before they have written a loop that compiles, or read about async before they can interpret an error type, and then conclude the language is hostile. It is demanding, but it is demanding in a sequence, and the sequence below is the one that keeps each new idea resting on something you already used. Every program in this post was compiled and run on Rust 1.99 with edition 2024, standard library only, so the compiler messages you see are the ones you will see.

The path has six stages. Each lists what to learn, what to ignore for now, and a concrete test that tells you to move on. That last part is the point: most advice says "learn ownership" and never says when you have.

Why the order matters

Rust's difficulty is not spread evenly. Syntax takes a weekend. The ownership rules take a few weeks to become reflex. Traits and generics take a month or two to use well, and async can take longer than the rest combined. Because every later topic assumes the earlier ones, the cost of learning in the wrong order is not just confusion. It is learning the workaround before the problem: a beginner who meets Arc<Mutex<T>> before understanding why a plain reference cannot cross threads memorises a pattern and cannot adapt it. The three places learners stall most often are the first ownership error, the first lifetime error and the first trait bound error. The path puts each one in front of you at a time when it is small. Why Rust feels hard has the longer account of what is and is not difficulty you can avoid.

Stage 1: install, run, and write small programs (days 1 to 4)

Install the toolchain with rustup (the install guide covers the choices), run cargo new, and read what cargo run prints. Then write programs that do arithmetic and print: a temperature converter, a multiplication table, FizzBuzz. Learn let, mut, the integer and float types, if, for over a range, match on numbers, and functions with typed parameters. This is a complete program you can type in and run.

rust
fn c_to_f(c: f64) -> f64 {
    c * 9.0 / 5.0 + 32.0
}

fn main() {
    for c in [-40.0, 0.0, 36.6, 100.0] {
        println!("{c:>6.1} C = {:>6.1} F", c_to_f(c));
    }
}
Text
 -40.0 C =  -40.0 F
   0.0 C =   32.0 F
  36.6 C =   97.9 F
 100.0 C =  212.0 F

Three things to take from it, in the order they appear. The function ends without a semicolon, which makes its last line the return value: Rust is expression-based, and a semicolon turns an expression into a statement that yields nothing. The array [-40.0, 0.0, 36.6, 100.0] is iterated by value, and the compiler infers every element as f64 from the function it is passed to. And the format string {c:>6.1} means right-align in six columns with one decimal, which you will want constantly, and which variables and mutability and the formatting pages cover. The row at -40 is the one place Celsius and Fahrenheit agree, which makes a handy sanity check.

Ignore for now: modules beyond one file, macros you write yourself, generics, lifetimes, unsafe, async. Move on when you can write a 40-line program with two functions and a loop without opening a reference, and when you read the compiler's output top to bottom instead of looking only for the red line.

Stage 2: ownership and borrowing (the next two to three weeks)

This is the stage that decides whether you stay. Do not rush it and do not read around it. Learn the rule (each value has one owner, and assigning or passing by value moves it), then the two kinds of reference: shared &T, any number at once, and exclusive &mut T, one at a time and not alongside a shared one. Write programs that pass strings and vectors to functions and get them back. Expect the compiler to reject a lot of what you write, and treat each rejection as the lesson. Ownership explained is the reference to keep open, and the single most common first error is covered in E0382, borrow of moved value.

The temptation in this stage is to silence every error with .clone(). Cloning is legitimate and you should use it freely while the rules settle. It is worth knowing what it costs, so that you know when to stop. Here is a measurement of the same function called two hundred times over a hundred thousand log lines, once receiving a fresh clone of the vector each time and once borrowing it.

rust
use std::hint::black_box;
use std::time::Instant;

fn total_len_owned(lines: Vec<String>) -> usize {
    lines.iter().map(|l| l.len()).sum()
}

fn total_len_borrowed(lines: &[String]) -> usize {
    lines.iter().map(|l| l.len()).sum()
}

fn main() {
    let lines: Vec<String> = (0..100_000).map(|i| format!("log line number {i} with some padding text")).collect();
    let rounds = 200;

    let t = Instant::now();
    let mut sum = 0;
    for _ in 0..rounds {
        sum += total_len_owned(black_box(lines.clone()));
    }
    println!("clone per call:  {:>8.2?}  (sum {sum})", t.elapsed());

    let t = Instant::now();
    let mut sum = 0;
    for _ in 0..rounds {
        sum += total_len_borrowed(black_box(&lines));
    }
    println!("borrow per call: {:>8.2?}  (sum {sum})", t.elapsed());
}
Text
clone per call:  683.02ms  (sum 877778000)
borrow per call:   5.08ms  (sum 877778000)

Release build, Apple M1 Max, and I ran it three times: the clone variant took between 666 ms and 1.02 s, the borrowed variant about 5 ms every time. Cloning a Vec<String> allocates once per string, so two hundred rounds make twenty million allocations, and that is the entire gap. The black_box calls stop the optimiser from noticing the result is unused and removing the work, which is the thing to check whenever a micro-benchmark looks too good. Read it as a rule of thumb, not a law: a clone of a small string in a one-off setup path costs nothing you will notice, a clone inside a loop over large data is the first thing to remove, and the right moment to remove it is when you understand why the borrow compiles.

Ignore for now: explicit lifetime annotations, Rc and RefCell, interior mutability. Plenty of useful programs never need them, and the section after next shows why. Move on when you can write a function that takes &str, a function that takes &mut Vec<T> and one that takes ownership, and say which you reached for and why. A second check: you have stopped adding .clone() as a reflex and can describe, for each remaining one, the owner it creates.

The lifetime error you can postpone

At some point in stage two you will write a function that takes two references and returns one of them, and the compiler will ask for something you have not learned.

rust
fn longest(a: &str, b: &str) -> &str {
    if a.len() >= b.len() { a } else { b }
}

fn main() {
    println!("{}", longest("borrow", "checker"));
}
Text
error[E0106]: missing lifetime specifier
 --> src/main.rs:1:33
  |
1 | fn longest(a: &str, b: &str) -> &str {
  |               ----     ----     ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `a` or `b`
help: consider introducing a named lifetime parameter
  |
1 | fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
  |           ++++     ++          ++          ++

For more information about this error, try `rustc --explain E0106`.

The message is a precise description of a real ambiguity. The function returns a reference, but the signature does not say whether it points into a or b, and the caller needs to know, because the answer decides how long the result stays valid. The compiler's suggestion is correct, and there are two ways forward. One is to add the named lifetime it proposes. The other is to avoid the question by returning an owned value.

rust
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() >= b.len() { a } else { b }
}

fn longest_owned(a: &str, b: &str) -> String {
    if a.len() >= b.len() { a.to_string() } else { b.to_string() }
}

fn main() {
    println!("{}", longest("borrow", "checker"));
    println!("{}", longest_owned("borrow", "checker"));
}
Text
checker
checker

The annotation 'a says that the result lives no longer than either input, which is true whichever branch runs. It does not change how long anything lives. It describes a relationship the compiler then checks at every call. The second function sidesteps the problem by returning a String, at the cost of one small allocation. For a beginner both are fine, and which you choose should depend on whether you want the practice. What matters for the path is that most programs written in stages one to four can avoid lifetime annotations entirely by returning owned values and borrowing only in parameters. That is why the advice is to postpone them: you will learn them faster when you have a function that needs one, and a month of ordinary borrowing makes the explanation click.

Stage 3: enums, Option and Result (weeks 4 and 5)

Do this before traits. Every real Rust program is mostly enums and matches: a value that might be absent, an operation that might fail, a command that has several shapes. Learn defining your own enum with data in its variants, match and its exhaustiveness check, Option, Result, and the ? operator. The pages to read are enums explained, Option explained and Result explained. The program below is a small command interpreter that uses all three. It parses lines like add buy milk into a typed command, reports precise errors, and runs the result against a task list.

rust
use std::fmt;

#[derive(Debug, PartialEq)]
enum Command {
    Add(String),
    Done(usize),
    List,
}

#[derive(Debug, PartialEq)]
enum ParseError {
    Empty,
    Unknown(String),
    MissingArg(&'static str),
    BadIndex(String),
}

impl fmt::Display for ParseError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ParseError::Empty => write!(f, "type a command"),
            ParseError::Unknown(c) => write!(f, "unknown command `{c}`"),
            ParseError::MissingArg(c) => write!(f, "`{c}` needs an argument"),
            ParseError::BadIndex(s) => write!(f, "`{s}` is not a task number"),
        }
    }
}

fn parse(line: &str) -> Result<Command, ParseError> {
    let mut parts = line.trim().splitn(2, ' ');
    let verb = parts.next().filter(|v| !v.is_empty()).ok_or(ParseError::Empty)?;
    let rest = parts.next().map(str::trim).filter(|r| !r.is_empty());
    match verb {
        "add" => rest.map(|r| Command::Add(r.to_string())).ok_or(ParseError::MissingArg("add")),
        "done" => {
            let raw = rest.ok_or(ParseError::MissingArg("done"))?;
            raw.parse().map(Command::Done).map_err(|_| ParseError::BadIndex(raw.to_string()))
        }
        "list" => Ok(Command::List),
        other => Err(ParseError::Unknown(other.to_string())),
    }
}

fn main() {
    let mut tasks: Vec<(String, bool)> = Vec::new();
    for line in ["add buy milk", "add write post", "done 1", "done x", "fly", "", "add", "list"] {
        match parse(line) {
            Ok(Command::Add(t)) => tasks.push((t, false)),
            Ok(Command::Done(n)) => match tasks.get_mut(n.wrapping_sub(1)) {
                Some(task) => task.1 = true,
                None => println!("no task {n}"),
            },
            Ok(Command::List) => {
                for (i, (t, done)) in tasks.iter().enumerate() {
                    println!("{}. [{}] {t}", i + 1, if *done { 'x' } else { ' ' });
                }
            }
            Err(e) => println!("error: {e}"),
        }
    }
}
Text
error: `x` is not a task number
error: unknown command `fly`
error: type a command
error: `add` needs an argument
1. [x] buy milk
2. [ ] write post

Walk through the shape rather than the syntax. Command is the set of things the user can ask for, and each variant carries only the data it needs. ParseError is the set of ways parsing can go wrong, and its Display implementation turns each into a sentence. parse returns Result<Command, ParseError>, so the caller cannot get a command without handling the failure case. Inside it, ok_or converts an Option into a Result so that ? can return the error early, and the final match on the verb handles every case with a catch-all for unknowns. The loop in main then matches on the outcome: the output shows the bad number, the unknown verb, the empty line and the missing argument each caught with a message that names the problem, and then the final list prints the one completed task and the one still open. Notice what is absent: no exceptions, no null checks, no defensive code. The types carry the cases.

Ignore for now: custom error crates (thiserror, anyhow), which make more sense once you have written the boilerplate once. Move on when you can model a small domain as an enum on your own and let the compiler tell you every place a new variant needs handling.

Stage 4: traits, generics and iterators (weeks 6 to 9)

Traits are the second large idea in the language. They describe shared behaviour, and generics let a function work for any type that has it. Start with the standard traits you will implement most: Display, Debug, Clone, PartialEq, Default. Then write a generic function and let it fail, because the error is the clearest explanation of what a trait bound is.

rust
fn largest<T>(items: &[T]) -> &T {
    let mut best = &items[0];
    for item in items {
        if item > best {
            best = item;
        }
    }
    best
}

fn main() {
    println!("{}", largest(&[3, 9, 4]));
}
Text
error[E0369]: binary operation `>` cannot be applied to type `&T`
 --> src/main.rs:4:17
  |
4 |         if item > best {
  |            ---- ^ ---- &T
  |            |
  |            &T
  |
help: consider restricting type parameter `T` with trait `PartialOrd`
  |
1 | fn largest<T: std::cmp::PartialOrd>(items: &[T]) -> &T {
  |             ++++++++++++++++++++++

For more information about this error, try `rustc --explain E0369`.

Inside a generic function the compiler knows nothing about T, so it will not let you compare two of them: not every type has an ordering. The message even prints the fix, which is to restrict T to types that implement PartialOrd. A bound is a promise about what callers may pass, and the function body may then use only what the promise covers. E0369 has the other common cases. Here is the corrected version, which also fixes a second problem the first draft ignored: indexing items[0] would panic on an empty slice, so the function returns an Option.

rust
fn largest<T: PartialOrd>(items: &[T]) -> Option<&T> {
    let mut iter = items.iter();
    let mut best = iter.next()?;
    for item in iter {
        if item > best {
            best = item;
        }
    }
    Some(best)
}

fn main() {
    println!("{:?}", largest(&[3, 9, 4]));
    println!("{:?}", largest(&["pear", "apple", "zebra"]));
    println!("{:?}", largest(&[1.5, 0.2]));
    println!("{:?}", largest::<u8>(&[]));
}
Text
Some(9)
Some("zebra")
Some(1.5)
None

One definition now serves integers, string slices and floats, and the fourth call shows the empty case returning None instead of crashing. The compiler generates a specialised copy for each type, so there is no runtime penalty. Iterators belong in this stage too, because map, filter and collect are generic functions whose bounds you can now read. Traits explained and iterators explained are the two to work through.

Ignore for now: trait objects beyond the basics, associated types in your own traits, and anything that reads like type-level programming. Move on when you can write your own trait with two implementers, a generic function bounded by it, and explain in a sentence why the compiler generated two copies.

Stage 5: one real project (weeks 8 to 12, overlapping stage 4)

Tutorials end; projects do not, and the gap between the two is where fluency comes from. Build one small command-line tool that reads input, processes it and sets an exit status. A text search tool is the standard choice, because it touches files, strings, iterators, errors and tests in a single page. This version uses only the standard library, reads two arguments and an environment variable, and ships with unit tests.

rust
use std::env;
use std::fs;
use std::process::ExitCode;

fn search<'a>(needle: &str, text: &'a str, ignore_case: bool) -> Vec<(usize, &'a str)> {
    let needle = if ignore_case { needle.to_lowercase() } else { needle.to_string() };
    text.lines()
        .enumerate()
        .filter(|(_, line)| {
            if ignore_case {
                line.to_lowercase().contains(&needle)
            } else {
                line.contains(&needle)
            }
        })
        .map(|(i, line)| (i + 1, line))
        .collect()
}

fn main() -> ExitCode {
    let args: Vec<String> = env::args().skip(1).collect();
    let (needle, path) = match args.as_slice() {
        [needle, path] => (needle, path),
        _ => {
            eprintln!("usage: path_grep <needle> <file>");
            return ExitCode::from(2);
        }
    };
    let text = match fs::read_to_string(path) {
        Ok(t) => t,
        Err(e) => {
            eprintln!("{path}: {e}");
            return ExitCode::from(2);
        }
    };
    let ignore_case = env::var_os("IGNORE_CASE").is_some();
    let hits = search(needle, &text, ignore_case);
    for (n, line) in &hits {
        println!("{n}: {line}");
    }
    if hits.is_empty() { ExitCode::from(1) } else { ExitCode::SUCCESS }
}

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

    const TEXT: &str = "Rust is fast\nrust is safe\nGo is simple";

    #[test]
    fn case_sensitive_by_default() {
        assert_eq!(search("Rust", TEXT, false), vec![(1, "Rust is fast")]);
    }

    #[test]
    fn can_ignore_case() {
        assert_eq!(search("rust", TEXT, true).len(), 2);
    }

    #[test]
    fn no_match_is_empty() {
        assert!(search("zig", TEXT, false).is_empty());
    }
}

The function search is the interesting part, and it is where the lifetime you postponed shows up naturally. It returns lines borrowed from text, so the signature says the output lives as long as the input text: 'a ties them together, and the needle, which is not returned, has no annotation. That is a lifetime written for a reason, not on principle, and by now it reads as sensible. main returns an ExitCode rather than panicking, which is what shell scripts rely on: 0 for matches, 1 for none, 2 for a usage or file error, the same convention grep uses. Running it with a sample file shows all four paths.

Text
$ path_grep rust sample.txt
2: rust is safe
exit status 0
$ IGNORE_CASE=1 path_grep rust sample.txt
1: Rust is fast
2: rust is safe
exit status 0
$ path_grep zig sample.txt
exit status 1
$ path_grep rust nothere.txt
nothere.txt: No such file or directory (os error 2)
exit status 2

The tests at the bottom run with cargo test, and they live in the same file as the code they check, which is the Rust convention (unit tests in a module explains why).

Text
$ cargo test --bin path_grep
running 3 tests
...
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Three passing tests is a small result, and also the whole point: you changed nothing about how the code is built to get them. Extend the tool in the direction that interests you, perhaps with a real argument parser from clap to release, recursive directory walking, or colour. A longer treatment of the same idea is building a grep clone, and project ideas lists others sized for this stage. Run cargo clippy on it; its suggestions are a free mentor (Clippy lint groups explains what the levels mean).

Move on when you have shipped the tool to somebody else, even one colleague, and fixed something they reported. That is the point where you have used Rust against real use rather than against exercises.

Stage 6: pick a direction (month 3 onward)

Only now does it make sense to learn the big optional topics, and you choose by what you are building rather than by order. A web service leads you to async and a framework: the axum production guide is the continuation. A systems or embedded interest leads you to unsafe, FFI and no_std. A library author needs semver, documentation and the API guidelines. Macros come last for nearly everyone, because you read them for a long time before you write one worth maintaining. Interview preparation, if that is your motivation, is a separate list in Rust interview questions.

The dead ends

These are the patterns that stall people, and each has a fix.

  • Reading the book cover to cover before writing code. Ownership read as theory does not turn into reflex. Alternate a chapter with an hour of small programs, and keep the programs.
  • Learning async first. A web-service job description makes async look like the starting point. It is the opposite: async errors are traits, lifetimes and ownership at the same time, and without the earlier stages they are unreadable.
  • Cloning to make errors vanish, forever. It is fine for two weeks. If it is still your only tool after a month, return to stage two with one specific program and remove every clone you can.
  • Fighting the compiler by guessing. Random edits to see what compiles teach nothing. Read the whole message, including the help: lines, since the suggestion is right more often than not.
  • Chasing performance early. Rust is fast by default. Measuring and optimising before the program is correct is the surest way to write unreadable code you must rewrite anyway.

When this path is the wrong one

The order above suits someone who knows another language and has a few hours a week. Three kinds of reader should change it.

  • You come from C or C++. You already hold the memory model, so ownership will feel like a compiler-enforced version of discipline you have. Go straight to stage three and spend your time on traits and the places Rust differs from RAII, rather than the early stages.
  • You are targeting embedded or kernel work. The first stages still apply, but you will leave the standard library early: no_std, interrupts and register access come before iterators and collections.
  • You have a deadline that forces async or a framework first. Then do the minimum of stages one to three in a week and learn the rest alongside the work. It is slower and more painful, and the alternative is not shipping. Pair with someone who knows the language if you can.

Total time varies more than any schedule admits. Two to four weeks of steady practice gets most people to useful small programs, and fluency with traits takes a few months. Treat the week numbers as an order and not a deadline, and use the exit tests, not the calendar, to decide when to advance.

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.