A self-directed curriculum · read top to bottom

Learning Rust
by watching memory

Twelve chapters ordered by dependency, not by difficulty. Every idea that makes Rust hard is a claim about where data lives and who is allowed to touch it — so every chapter here comes with instruments you can operate.

~10,000 words 54 interactive figures 12 exercises Ch. 2–4 carry the weight
Chapter 01

Setup and Orientation

rustup · cargo · editions · what the language is refusing to trade

Install through rustup, never through a system package manager. rustup manages toolchains the way nvm manages Node versions, and it's the only supported way to switch between stable, beta, and nightly, or to add targets for cross-compilation. One curl command gets you rustc, cargo, rustfmt, and clippy. Add the rust-analyzer extension to your editor before you write a single line — the inline type hints are not a convenience, they're a teaching tool, because Rust's type inference is aggressive and seeing what it inferred is how you learn what it's doing.

Cargo is the build system, package manager, test runner, doc generator, and script entry point. cargo new hello scaffolds a project; cargo run builds and runs; cargo check type-checks without generating a binary, which is three to five times faster and is what you should be running in a watch loop while writing code. cargo clippy runs a lint suite that catches idiom violations, not just errors, and in the first few months it functions as a free code reviewer. cargo fmt ends all formatting debate by fiat.

Fig 1.1

The cargo loop — what each command actually does

click a command
Read this as — the reason cargo check exists is that most of your compile time is code generation and optimization, and while you're writing you only need the type and borrow analysis. Put cargo watch -x check in a terminal and leave it there.

Rust editions are a compatibility mechanism worth understanding on day one because you'll see them referenced constantly. An edition (2015, 2018, 2021, 2024) is a per-crate opt-in to language changes that would otherwise break existing code — new keywords, changed defaults, adjusted closure capture rules. Crates on different editions link together fine. When you read a Stack Overflow answer from 2017, edition differences are one of the reasons it might not compile. Start on the latest edition your toolchain supports.

The thing to understand about Rust's design is what it's refusing to trade. Most languages pick two of: memory safety, no garbage collector, and high-level abstraction. Rust wants all three, and the price is that the compiler has to statically prove things about your program that other compilers don't attempt. That proof obligation lands on you as the borrow checker. Everything painful about learning Rust traces back to this single decision, and everything good about Rust does too.

Fig 1.2

Pick two — the tradeoff every language makes

click a corner or an edge
Read this as — the borrow checker is not a safety feature bolted onto a fast language. It is the mechanism by which the third corner is reached at all. Every hour you spend fighting it is the cost of the corner you're standing on.

"Zero-cost abstraction" is the other design commitment, inherited from C++. It means an abstraction should compile down to what you'd have written by hand — iterators become loops, generics become monomorphized concrete functions, Option<&T> is the same size as a pointer because the compiler uses the null pointer as the None case. When you write vec.iter().filter(…).map(…).sum(), the machine code is a single loop with no allocations. This matters for your learning because it means you should reach for the high-level construct first; you're rarely paying for it.

Compile times are the standing complaint and they're real. Monomorphization generates a lot of code, LLVM optimizes all of it, and dependency trees are deep. Mitigations: use cargo check in your write loop and cargo build only when running; install a faster linker (lld or mold); keep dependencies lean; split large projects into workspace crates so a change to one doesn't rebuild everything. Accept that the loop is slower than Python's and note that you'll spend proportionally less time debugging at runtime.

The compiler's error messages are the best in mainstream programming and you should read them completely rather than skimming for a line number. A borrow checker error will typically show you three spans — where the borrow started, where it conflicted, and where the original was still needed — plus a suggested fix that is right maybe 70% of the time. Errors have codes; rustc --explain E0502 prints a page-long explanation with examples. Treat the compiler as a pair programmer with a narrow specialty and no social skills.

Fig 1.3

How to read a rustc error

hover the parts
AnatomyHover any part of the message above. Every rustc diagnostic has the same five components, and the one people skip — the note — is usually the one that explains the rule you just broke.
Read this as — this gutter-and-caret notation is the most characteristic artifact of Rust's world, and it recurs throughout this page. Whenever a figure below shows you an error, it draws it the way the compiler would.

Your mental model needs one shift before anything else lands: in Rust, every value has exactly one owner at any time, and the compiler tracks that ownership through your entire program at compile time. This is not a runtime check, not a convention, not something you can opt out of in safe code. Coming from a garbage-collected language, you're used to variables being names that point at objects floating in a shared heap. In Rust, a variable is more like a container that holds the value, and passing it somewhere means the value physically moves out.

Two reference books worth having open. The Rust Programming Language (the "Book," free at doc.rust-lang.org/book) is the official tutorial and is genuinely well-written. Programming Rust by Blandy, Orendorff, and Tindall is denser, assumes you know systems programming, and explains why more often than the Book does. Use the Book as your spine and Programming Rust when an explanation doesn't land. Rust by Example and the std library docs — which are excellent and full of runnable examples — are for lookups.

Exercise

cargo new a project, write a program that reads a file path from std::env::args(), counts the lines, and prints the count. You will hit ownership problems immediately. Don't fight them yet — use .clone() freely and note where you needed it. You'll come back and remove them after Chapter 3.

You've got it

…when you can start a project, add a dependency from crates.io, and run tests without looking anything up.

Chapter 02

Ownership

moves · Copy · Drop · stack and heap · the owned/borrowed pairs

Ownership is one rule with three clauses: every value has a variable that owns it; there is exactly one owner at a time; when the owner goes out of scope, the value is dropped. Everything else in Rust's memory model is a consequence of these. Read them again in a week and they'll mean more.

Start with why this exists. Manual memory management (C) is fast but lets you free twice, free too early, or never free. Garbage collection (Java, Go, Python) is safe but costs you throughput, latency spikes, and runtime overhead. Reference counting everywhere (Swift, Python) is a middle path with its own costs. Rust's insight is that the vast majority of allocations have a statically knowable lifetime — you can tell by reading the code where the value stops being needed — so the compiler can insert the deallocation for you and verify nobody touches it afterward. Ownership is the bookkeeping that makes this provable.

The mechanic that will bite you first is the move. When you assign a value to a new variable, pass it to a function, or return it, ownership transfers and the original binding becomes invalid. This isn't a shallow copy or a deep copy — it's a move. The three-word String header (pointer, length, capacity) is memcpy'd to the new binding, and the old one is statically marked dead. No heap data is copied. The compiler then knows only one binding will run the destructor, so no double free is possible.

Fig 2.1

A move, one step at a time

step through · switch the type
Type
Stack — frame of main
Heap
Read this as — watch the heap column while you step. It never changes. A move is a three-word stack copy plus a compile-time note that the old name is dead; it is not a deep copy and it is not free-and-reallocate. Now switch the type to i32 and step again: the same source code behaves completely differently, and the difference is one trait.

Not every type moves. Types that are cheap to duplicate and have no destructor implement the Copy trait — all the integer and float types, bool, char, and tuples or arrays composed entirely of Copy types. For these, assignment duplicates the bits and both bindings stay valid. The dividing line is precise: a type can be Copy only if it owns no heap allocation and no other resource, because Copy and Drop are mutually exclusive. i32 is Copy; String is not; [u8; 32] is; Vec<u8> is not.

Fig 2.2

Does it move, or does it copy?

click any type
Pick a typeThe test is mechanical: does the type own a resource that needs releasing? If yes, it must move, because two owners would mean two releases.
Read this as — you can predict this without memorizing a list. Anything holding a heap pointer, a file descriptor, a lock, or a socket moves. Anything that is just bits on the stack copies. The compiler enforces the rule with a hard constraint: a type cannot implement both Copy and Drop.

Drop is the destructor and it runs deterministically at end of scope, in reverse declaration order. This is RAII from C++ and it generalizes past memory: a File closes when dropped, a MutexGuard unlocks, a TcpStream disconnects, a database transaction rolls back. You almost never call drop() manually, and you rarely implement Drop yourself, but understanding that scope exit means resource release is what lets you reason about when things happen. There is no finally block in Rust because there doesn't need to be one.

Fig 2.3

Scope exit runs destructors in reverse

step through the scopes
Live values
Read this as — the reverse order is not arbitrary. A value declared later may hold a reference into a value declared earlier, so tearing down in reverse guarantees nothing is destroyed while something still depends on it. This is also why MutexGuard unlocking at end of scope is safe rather than lucky.

The stack/heap distinction matters more here than in managed languages, so make it concrete. String is a struct on the stack containing a pointer to heap bytes, plus length and capacity. &str is a fat pointer — pointer plus length — that borrows bytes owned by someone else, whether that's a String, a static string literal baked into the binary, or a memory-mapped file. Vec<T> and &[T] have the same relationship. The owned/borrowed pairing shows up everywhere in the standard library once you see the pattern.

Fig 2.4

Owned and borrowed come in pairs

drag the slice handles
Pair
Stack
Heap buffer — one allocation, owned by the left-hand type
slice start len
Read this as — the borrowed half is always a fat pointer into somebody else's allocation. Move the handles and watch: no allocation happens, ever. That's why the convention below costs nothing.

The convention that follows: take &str in function parameters and return String when producing new data. A function taking &str can be called with a literal, a String (which auto-derefs), or a slice of either. A function taking String forces every caller to own or clone. This single habit removes a surprising fraction of beginner ownership friction.

clone() is the escape hatch and you should use it freely for the first month, then start auditing. Cloning is not shameful — it's an explicit, visible allocation, which is exactly what other languages do implicitly and constantly. The reason to eventually reduce it is that a .clone() you added to silence the compiler often indicates your data flow is genuinely confused, and the clone hides the confusion rather than resolving it. A .clone() you added because two parts of the program legitimately need independent copies is correct and permanent.

Ownership also gives you a design vocabulary you didn't have before. A function signature taking String says "I'm consuming this, you won't have it back." Taking &str says "I'm reading this and giving it back." Taking &mut String says "I'm modifying yours in place." Returning String says "this is yours now." In most languages a signature tells you types; in Rust it tells you the whole contract, and reading signatures becomes a much higher-bandwidth activity.

Fig 2.5

A signature is a contract — read four of them

click a signature
Read this as — the fourth column of that grid is the one people forget exists. &mut String means the caller keeps the value and you changed it, which is a different promise from all three others.
Exercise

Write fn consume(s: String) -> usize returning the length, then try to use the string afterward. Read the error. Now write fn borrow(s: &str) -> usize. Then write a function taking a Vec<String> and try to iterate it twice — once with for s in v and once with for s in &v — and understand precisely why one of them breaks the second loop.

You've got it

…when you can predict, before compiling, whether a given line will move or copy.

Chapter 03

Borrowing and References

aliasing XOR mutability · NLL · reallocation · split borrows

Ownership alone would be unbearable — you'd be passing values back and forth from every function call just to keep using them. Borrowing is the relief valve: a reference lets you access a value without taking ownership. &T is a shared (immutable) reference, &mut T is an exclusive (mutable) one, and the entire borrow checker is enforcing one rule about how these can coexist.

The rule: at any given point, for any given value, you may have either any number of shared references or exactly one exclusive reference, never both. This is often taught as "one mutable XOR many immutable." The naming is slightly misleading — &mut is better understood as exclusive rather than mutable, because exclusivity is the actual guarantee and mutability is what exclusivity permits.

Fig 3.1

Aliasing XOR mutability — try to break it

add and remove borrows
The value
v : Vec<i32>
Live borrows
&T — many allowed, read only &mut T — exactly one, excludes everything
Read this as — there are only three legal states in this figure: no borrows, N shared borrows, or exactly 1 exclusive borrow. Every combination outside those three is what the borrow checker rejects, and every memory bug it prevents is an instance of one of them.

Why this rule and not some other one? Because aliasing plus mutation is the root cause of an enormous class of bugs across all of programming. Iterator invalidation — mutating a collection while iterating it — is aliasing plus mutation. Data races are aliasing plus mutation across threads. Aliased writes defeating compiler optimizations is the same problem seen from the optimizer's side. Rust picked the one restriction that eliminates all of these at once, and got thread safety essentially for free as a side effect, which is why Chapter 11 will be shorter than you expect.

Fig 3.2

What the rule is actually preventing

run the reallocation
Language
Stack
Heap
Read this as — push may exceed capacity, allocate a bigger buffer, copy, and free the old one. Any reference into the old buffer is now pointing at freed memory. C++ compiles this and hands you a use-after-free at runtime; Rust rejects it at compile time. The rejection is not pedantry, it is the same bug caught earlier.

Borrows end at their last use, not at end of scope. This is called non-lexical lifetimes (NLL) and it's been the behavior since 2018, but a lot of older tutorials describe the stricter lexical rules. In practice it means the failing example above compiles fine if you move the println! above the push. When you get a borrow error, one of the first questions to ask is whether you can shorten the borrow's live range by reordering, or by extracting the value you need — let first = v[0]; copies the i32 and holds no borrow at all.

Fig 3.3

Non-lexical lifetimes — move the last use

reorder the lines
Where does println!("{first}") go?
Read this as — the blue bar is the live range of the shared borrow, and it ends at its last use, not at the closing brace. Under the pre-2018 lexical rules it would have run to the bottom of the function and even the "before push" ordering would have failed. Reordering to shorten a borrow is the single most common fix in real code.

Methods take self in one of four forms and the choice is a meaningful part of API design. &self reads. &mut self mutates. self consumes — this is how builder patterns chain, since .build() taking ownership means you can't reuse a half-consumed builder, and how conversions like into_bytes() avoid copying. mut self consumes and lets you mutate the local copy before returning it. When you read fn into_iter(self) versus fn iter(&self), the receiver tells you whether the collection survives.

The error you'll hit most is "cannot borrow x as mutable more than once at a time," usually from calling two &mut self methods in one expression, or from a nested method call where the outer call already holds a mutable borrow. The standard fixes, roughly in order of preference: shorten one borrow by binding an intermediate value to a let; restructure so the two mutations happen sequentially instead of nested; use a split borrow; as a last resort, clone.

Fig 3.4

Split borrows — fields yes, methods no

click each version
What the borrow checker sees
Read this as — the borrow checker tracks borrows at field granularity inside a struct, but it cannot see through a method call. A method taking &mut self borrows the whole struct as far as the checker is concerned, even if its body only touches one field. This is the single most useful piece of borrow-checker mechanics that beginners don't know.

Reborrowing is the invisible mechanism that makes &mut usable. Passing a &mut T to a function doesn't move it — the compiler inserts an implicit reborrow (&mut *r), so the original reference is merely suspended for the duration of the call rather than consumed. This is why you can call a function taking &mut self in a loop. You'll only notice reborrowing when it fails to trigger, typically when storing a &mut in a struct, and knowing the word gives you something to search for.

The way to build intuition here is to deliberately write code that fails. Try to hold a reference to a vector element while pushing. Try to return a reference to a local variable. Try to iterate a HashMap while inserting into it. Read each error fully, including the note and help sections. After perhaps thirty of these, the rules stop being rules you're checking against and start being a shape you can feel before you type.

Exercise

Write a struct Inventory { items: Vec<Item>, log: Vec<String> } with a method that finds an item by name and appends a line to the log. Do it without cloning. You'll hit a borrow conflict; solve it by structuring the code so the lookup finishes before the log write begins.

You've got it

…when borrow errors stop feeling arbitrary and start reading as "you have two things pointing at one thing and one of them writes."

Chapter 04

Lifetimes

regions, not durations · elision · 'static's two meanings

Lifetimes are the part everyone dreads and they're conceptually simpler than their reputation. A lifetime is a compile-time label naming a region of code during which a reference is valid. 'a is not a duration, not a runtime value, and not something that affects your generated code at all. It's an annotation that lets the compiler relate the validity of one reference to another, and it exists solely so that you can express constraints the compiler cannot infer.

Fig 4.1

A lifetime is a region of code

drag the scope boundary
Inner scope ends at line
Read this as — drag until the green bar (the value's life) ends before the blue bar (the reference's life) does. That overhang is the entirety of "borrowed value does not live long enough." Lifetime annotations describe these bars; they never lengthen them.

Every reference has a lifetime whether you write it or not. The compiler infers them in the vast majority of cases through elision rules, which are three mechanical rules applied in order: each elided input lifetime gets its own parameter; if there's exactly one input lifetime, it's assigned to all output lifetimes; if one of the inputs is &self or &mut self, that lifetime is assigned to all outputs. The third rule is why methods almost never need annotations — returning a reference derived from &self is the overwhelmingly common case and it's handled.

Fig 4.2

Elision — watch the compiler fill them in

pick a signature, then step
Read this as — you write annotations only when these three rules produce nothing or produce the wrong thing. That's rarer than the reputation suggests; rule 3 alone covers most methods you will ever write.

You need explicit lifetimes when elision produces the wrong answer or no answer. The canonical case is two input references and one output, where the compiler cannot know which input the output borrows from. Read the annotation as a contract rather than a fact: the returned reference will be valid for at least as long as the shorter of the two inputs. You aren't setting a lifetime, you're stating a relationship, and the compiler then checks both that your body honors it and that every caller satisfies it.

Fig 4.3

longest<'a> — the output is the intersection

drag both inputs
1fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
2 if x.len() > y.len() { x } else { y }
3}
x lives until y lives until result used at
Read this as — writing 'a on both parameters unifies them, and unification takes the shorter. The result is usable only where both inputs are still alive. Push the "result used at" handle past the shorter input and the contract breaks.

The mistake that generates the most confusion is thinking lifetime annotations extend how long something lives. They don't and can't. Nothing you write in the type system keeps a value alive; lifetimes are descriptive, not prescriptive. If you write a function returning &'a str from a String created inside the function, no annotation saves you — the String drops at the closing brace and the reference would dangle. The fix is always to return owned data (String) or to accept the data from the caller so the caller owns it.

'static means "valid for the entire program duration," and it appears in two distinct roles that are worth keeping separate. As a reference lifetime, &'static str is a string literal baked into the binary's data segment. As a bound, T: 'static means the type contains no references with any lifetime shorter than the program — which is satisfied by every owned type, including a String you allocate at runtime. String: 'static is true and confuses people who read 'static as "lives forever." Thread spawning requires T: 'static in the bound sense, because the thread might outlive the spawning scope.

Fig 4.4

Two different 'statics

click a type
As a reference lifetime
As a bound: T: 'static
Read this as — a heap-allocated String satisfies T: 'static. It does not live forever; it contains no borrowed references, which is a completely different claim. Once you separate these two readings, most 'static error messages become legible.

Structs holding references need lifetime parameters, and this is the point where beginners typically overreach. struct Parser<'a> { input: &'a str, pos: usize } is a perfectly good design for a zero-copy parser — the struct borrows a buffer someone else owns and can't outlive it. But if you find yourself writing lifetime parameters on structs early in your Rust career, you're usually building something that would be simpler with owned fields. Use String instead of &'a str in your structs until you have a measured reason not to. Zero-copy is an optimization, and it's an optimization with a real complexity cost that propagates through every type that touches yours.

Lifetime errors have a small vocabulary and learning to translate them is most of the battle. "Borrowed value does not live long enough" means a reference outlives its referent — check whether you're returning or storing a reference to a local. "Cannot infer an appropriate lifetime" usually means elision rule two or three didn't apply and you need to state the relationship. "Lifetime may not live long enough" in a closure typically means you need move. "Argument requires that x is borrowed for 'static" means you handed a borrow to something that outlives your scope, like a thread or a boxed trait object.

Higher-ranked trait bounds — for<'a> Fn(&'a str) -> &'a str — are the advanced corner, and they show up when a closure must work for any lifetime the caller chooses rather than one specific lifetime. You'll encounter this writing generic functions that accept closures over references. You do not need to understand it for months. When you meet for<'a> in an error message, the practical response is usually to add an explicit type annotation to the closure's parameter.

The reason lifetimes feel hard out of proportion to their complexity is that they're the point where the compiler stops accommodating you and makes you articulate a design decision — specifically, who owns this data and how long does it live. That question exists in every language. In Python or Java it's answered implicitly by the garbage collector and you never think about it. Rust makes you write the answer down, and if you don't yet know the answer, the annotation you're trying to write is undefined.

Exercise

Write a struct WordIter<'a> that borrows a &'a str and implements Iterator<Item = &'a str> yielding whitespace-separated words, without allocating. Then write the same thing returning String for each word and compare the signatures. Notice how much simpler the owned version is and form an opinion about when the complexity is worth it.

You've got it

…when you can read a signature with three lifetime parameters and say what it promises the caller.

Chapter 05

Structs, Enums, and Pattern Matching

algebraic types · tagged unions · exhaustiveness · illegal states

Rust's type system is algebraic, which means it has products (structs — this AND that) and sums (enums — this OR that), and the sums are what most languages are missing. If you're coming from Java, Python, Go, or C, enums are the feature that will change how you design programs, more than ownership does.

A Rust enum is a tagged union: each variant can carry different data, and a value is exactly one variant at a time. This lets you encode "one of these possibilities, each with its own payload" directly in a type, where other languages force you into nullable fields, inheritance hierarchies, or a status field plus a comment explaining which other fields are valid when.

Fig 5.1

An enum in memory — tag plus the widest payload

click a variant
Layout — 16 bytes total
discriminant (tag) active payload padding — unused for this variant
Read this as — an enum is exactly as large as its widest variant plus a tag, rounded for alignment. That padding is why you box large variants: one 200-byte variant makes every value 200 bytes, even the None-ish ones. Now look at Fig 5.2 for the case where the tag disappears entirely.
Fig 5.2

The niche optimization — why Option<&T> is free

compare the three
Read this as — a reference is never null, so the all-zeros bit pattern is an unused "niche" in its value space, and the compiler spends it on None. This is the mechanism behind the claim that Rust removed null without paying for it: you get the compile-time check and the same machine representation C uses.

Option<T> and Result<T, E> are ordinary enums defined in the standard library, not compiler magic. Option is Some(T) or None; Result is Ok(T) or Err(E). Rust has no null, and the absence of null is the direct consequence of having a good enum: you don't need a null when you can say Option<T> in the type and force every consumer to handle the empty case. This is Hoare's billion-dollar mistake, un-made.

match is exhaustive, and exhaustiveness is the feature. The compiler will reject a match that doesn't cover every variant, which means adding a variant to an enum causes compile errors at every place that needs updating. This turns a whole class of runtime bug into a build failure and makes refactoring enum-heavy code dramatically safer than the equivalent in a language with non-exhaustive switches. Resist reaching for _ => catchall arms in your own enums — the catchall is exactly what discards the benefit.

Fig 5.3

Add a variant and watch the build break — usefully

toggle the variant and the catchall
Call sites in the crate
Read this as — turn the catchall on and the errors vanish, which feels like progress and is the opposite. With the catchall, the new variant silently falls into the default branch at every site and you find out in production. Exhaustiveness is a refactoring tool disguised as a restriction.

Patterns nest and destructure arbitrarily, and they appear in far more places than match. You can destructure in let, in function parameters, in for loops, in closure arguments. Match guards (Some(x) if x > 5) add conditions. @ bindings capture a value while also testing it (n @ 1..=5). | matches multiple patterns in one arm. .. ignores the rest of a struct or tuple. Learning the pattern grammar pays off continuously because patterns are load-bearing syntax throughout the language.

Fig 5.4

Pattern matcher — which arm fires?

pick an input value
Read this as — arms are tried top to bottom and the first match wins, so ordering is semantic. Watch the bindings panel: a pattern isn't just a test, it destructures and names the pieces in one move, which is why Rust code rarely needs accessor chains.

For the common cases where match is heavy, there's if let and let … else. The latter is the one to internalize — it gives you the early-return guard clause that keeps the happy path unindented, which is how most well-written Rust functions read.

if let Some(config) = maybe_config {
    apply(config);
}

let Some(config) = maybe_config else {
    return Err(Error::Missing);
};
// config is in scope for the rest of the function

Derive macros supply trait implementations mechanically. #[derive(Debug)] gives you {:?} formatting and you should put it on essentially every type you define. Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, and Default are the rest of the common set. Serde's Serialize/Deserialize join them the moment you touch JSON. Deriving is cheap and reversible, so err toward deriving more.

The newtype pattern — a tuple struct wrapping a single value, like struct UserId(u64) — is used constantly and for two reasons. It prevents mixing up values of the same underlying type, since you can't pass a UserId where an OrderId is expected even though both are u64, and it gives you a local type you can implement foreign traits on, which the orphan rule otherwise forbids. The runtime cost is zero.

The design discipline that emerges from all this is "make illegal states unrepresentable." Instead of a Connection struct with an is_connected: bool and an Option<Socket>, define enum Connection { Disconnected, Connected(Socket) }. Instead of validating a string at every use site, define struct Email(String) with a constructor returning Result<Email, ParseError> and make the field private — now possession of an Email is proof of validation. This style front-loads thinking and eliminates entire categories of test.

Fig 5.5

State space — struct of flags vs. enum

toggle the flags
Struct of flags — 2³ = 8 states
Enum — 3 states, all valid
Read this as — five of those eight states are nonsense that your code must nonetheless defend against, forever, at every call site. The enum doesn't discourage them; it makes them unspellable. This is the highest-leverage habit in the whole language and it costs nothing at runtime.
Exercise

Model a JSON value as an enum (Null, Bool, Number, String, Array(Vec<Json>), Object(HashMap<String, Json>)) and write a recursive function that pretty-prints it with indentation. Then write one that counts total nodes. You'll use nested patterns heavily and see why recursion over enums is pleasant here.

You've got it

…when you catch yourself redesigning a struct into an enum because two fields were never valid simultaneously.

Chapter 06

Error Handling

panic vs Result · the ? operator · thiserror and anyhow

Rust splits failure into two categories with different machinery, and the split is the first thing to get straight. Recoverable errors — a file is missing, input is malformed, the network hiccupped — are values of type Result<T, E> returned from functions. Unrecoverable errors — an invariant your code was supposed to maintain has been violated — are panics, which unwind the stack and abort the thread. The question to ask is not "how bad is this?" but "is the caller in a position to do something about it?"

Fig 6.1

Panic or Result? — answer two questions

walk the tree
Read this as — the deciding question is never severity. A missing config file is fatal to your program and still a Result, because somebody up the stack might want to fall back to defaults. An index out of bounds is trivial and still a panic, because there is no sensible recovery from a broken invariant.

panic! and its friends — unwrap, expect, array index out of bounds, integer division by zero, assert! — exist for the second category. They are not exceptions, because you're not meant to catch them: catch_unwind exists but is for thread boundaries and FFI, not for control flow. In a library, panicking on bad user input is a bug. In an application, expect("config.toml must exist") at startup is fine and often better than propagating an error you'd only print and exit on anyway.

The ? operator is what makes Result ergonomic. Placed after a Result-returning expression, it unwraps the Ok case or returns the Err early from the enclosing function. It also applies From::from to the error on the way out, which means ? will automatically convert error types if the conversion is implemented — and this single detail is what makes error propagation across layers tolerable.

Fig 6.2

Where ? sends the error

choose which call fails
Make this fail
Call stack — error travelling up
Read this as — the amber step is the one people don't know is happening. ? is not just an early return; it calls From::from on the error as it passes the function boundary. That implicit conversion is what lets a single function propagate three unrelated error types into one enum without a single match.

For a library, define your own error enum with a variant per failure mode, so callers can match on what went wrong. The thiserror crate derives the boilerplate — #[from] generates the From impl that ? uses, and #[error(…)] generates Display. You get a typed, matchable, well-formatted error for about six lines of declaration.

#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
    #[error("config file not found at {0}")]
    NotFound(PathBuf),
    #[error("failed to read config")]
    Io(#[from] std::io::Error),
    #[error("invalid TOML syntax")]
    Parse(#[from] toml::de::Error),
}

For an application, you usually don't need typed errors, because the top of main is going to print the thing and exit regardless. anyhow::Result<T> accepts any error type via ?, carries a backtrace, and supports .context("while loading user profile") to build a human-readable chain. The rule of thumb the ecosystem has settled on: thiserror in libraries, anyhow in binaries. A library that forces anyhow on its consumers has taken away their ability to handle specific failures.

Fig 6.3

thiserror or anyhow — what the caller can do

switch sides
Read this as — the axis is matchable specificity versus effortless propagation, and you can't have both maximally. Every error-handling design decision in Rust sits somewhere on this line, so decide which end your consumers are standing at.

Option and Result both have large combinator APIs and learning maybe a dozen of them removes most match boilerplate. map transforms the success value, and_then chains a fallible operation, unwrap_or and unwrap_or_else supply a default, ok_or and ok_or_else convert Option to Result, ok() converts the other way, ? handles propagation. filter, take, replace, and zip on Option are worth knowing. Read the std docs for both types end to end once; it's an hour and it pays back.

Fig 6.4

Combinator bench — feed a value through

click combinators to chain them
Start with
Read this as — every one of these short-circuits on the empty case, which is the whole point. Once a None or Err enters the pipeline it passes through untouched to the end, so you write the happy path as a straight line and never test for emptiness in the middle.

unwrap() deserves an explicit discipline because it's how most Rust programs crash. Use expect("message") instead — same behavior, but the message tells you which invariant broke, and writing the message forces you to articulate why you believe the value is there. In tests and prototypes, unwrap freely. In anything that runs unattended, treat each one as a claim you're prepared to defend.

Errors from multiple sources compose through Box<dyn Error> when you don't want a typed enum but also don't want a dependency. Box<dyn std::error::Error + Send + Sync + 'static> is the fully general form and is what anyhow::Error wraps. The downside compared to an enum is that callers can only print it or downcast; they can't match.

One habit that separates comfortable Rust from painful Rust: design your error type when you design the function, not after the compiler complains. Ask what can fail and what the caller would want to do differently for each failure. If the answer is "nothing different," collapse those into one variant. If the answer is "retry on this one but not that one," they're separate variants and the distinction has earned its place.

Exercise

Write a small CLI that reads a TOML config, validates two fields, and fetches a URL. Define a thiserror enum with distinct variants for missing file, bad TOML, failed validation, and HTTP failure. Then rewrite the same program with anyhow and .context(). Compare which one you'd rather maintain, and notice that the answer depends on whether anyone else is calling your code.

You've got it

…when you can decide between panic, a typed error, and anyhow without deliberating.

Chapter 07

Traits and Generics

monomorphization · dispatch · associated types · the orphan rule

Traits are Rust's mechanism for shared behavior, and they're closer to Haskell's typeclasses than to Java's interfaces. The key difference from interfaces: a trait implementation is a separate item from the type definition, so you can implement your trait for types you didn't write (impl MyTrait for String), and you can implement someone else's trait for your type (impl Display for MyType). Behavior isn't baked into the type at declaration; it's attached afterward, possibly by a third party.

Generics are monomorphized. When you write fn largest<T: PartialOrd>(items: &[T]) -> &T and call it with i32 and String, the compiler generates two specialized functions with no dynamic dispatch and full inlining. This is why generic Rust is as fast as hand-written concrete code, and also why compile times and binary sizes grow with generic usage. C++ templates work the same way; Java generics via erasure and Go's older interface-based approach do not.

Fig 7.1

Monomorphization — one function becomes many

add call sites
Call it with
What the compiler emits
binary size
Read this as — this is the whole trade. You get inlining, no vtable, and constant folding across the call boundary; you pay in code size and compile time. It's also why a deeply generic library can blow up your binary, and why dyn exists as a release valve.

Trait bounds constrain generic parameters and are how you state requirements. T: Clone + Send requires both. where clauses move the bounds off the signature line when they get long, and you should use them past two bounds because signature readability degrades fast. Bounds are checked at the definition site, not the call site, which means a generic function that compiles is guaranteed to work for every type satisfying its bounds — you can't get a template error deep inside a library the way you can in C++.

Associated types let a trait declare a type it works with, decided by the implementor rather than the caller. Iterator has type Item; — each iterator picks its own item type, and there's exactly one per implementor. Compare with generic parameters: trait Convert<T> could be implemented many times for one type with different Ts. Use an associated type when there's one natural answer per implementor, a generic parameter when there are several. Getting this wrong is a common early API mistake and it's usually the associated type you want.

Fig 7.2

Associated type or generic parameter?

try implementing twice
Read this as — the question is "how many times should one type be able to implement this trait?" One answer per implementor → associated type, and the caller never has to annotate. Many answers → generic parameter, and now every call site may need a turbofish.

Static and dynamic dispatch are two ways to use the same trait. impl Trait in argument position (fn draw(shape: impl Shape)) is sugar for a generic and dispatches statically. dyn Trait behind a pointer (Box<dyn Shape>, &dyn Shape) dispatches through a vtable at runtime. Reach for generics by default; reach for dyn when you need heterogeneous collections (a Vec<Box<dyn Shape>> holding circles and squares), when you want to reduce code bloat, or when the type isn't known until runtime.

Fig 7.3

Static vs. dynamic dispatch — follow the pointer

run a call
Read this as — a &dyn Trait is a fat pointer: data pointer plus vtable pointer. The call costs one extra indirection and, more importantly, blocks inlining, which is usually the larger cost. For a handful of calls it's noise; inside a hot loop it isn't.

Not every trait can become a dyn trait — the requirement is object safety, and it fails for traits with generic methods, methods returning Self, or associated constants, because a vtable can't represent them. When you get "the trait cannot be made into an object," the fix is usually to split the trait into an object-safe core and an extension trait with a blanket impl carrying the generic methods. The standard library does this with Iterator and its dyn-compatible subset.

The orphan rule governs where implementations can live: you may implement a trait for a type only if you own the trait or you own the type. This prevents two crates from providing conflicting impls of serde::Serialize for uuid::Uuid and having the choice depend on link order. When it blocks you — wanting to implement a foreign trait for a foreign type — the newtype pattern is the standard workaround, wrapping the foreign type in a local struct.

Fig 7.4

The orphan rule — who owns what

pick a trait and a type
Trait
Type
Read this as — only one square in this grid is forbidden, and it's forbidden because two unrelated crates could both fill it with different answers. The newtype escape hatch works because wrapping makes the type yours, which moves you out of that square.

Blanket implementations apply a trait to every type meeting a bound: impl<T: Display> ToString for T is why every displayable type has .to_string(). This is enormously powerful and is how extension-trait patterns work — define a trait with useful methods, blanket-impl it for everything satisfying some bound, and now the whole ecosystem has your methods. It's also a source of coherence conflicts, since a blanket impl claims a lot of ground.

There's a standard vocabulary of traits you should learn as a set, because they're how types plug into the language itself. Debug and Display for formatting. Clone and Copy for duplication. PartialEq/Eq and PartialOrd/Ord for comparison, where the Partial versions exist because floats have NaN and thus no total order. Default for zero-argument construction. From and Into for conversions — implement From, get Into free via a blanket impl. TryFrom/TryInto for fallible conversions. Iterator and IntoIterator for looping. Deref for smart pointers, which is why &String works where &str is expected. Drop for cleanup. Send and Sync for threading.

Fig 7.5

The standard trait vocabulary

click any trait
Read this as — these aren't a library, they're the language's extension points. Implementing Display makes {} work; implementing Iterator makes for work; implementing From makes ? convert. Learning the set is how you stop writing to_my_string() helpers.

The design shift generics enable is worth naming: you stop writing functions that take concrete types and start writing functions that take capabilities. fn process(path: impl AsRef<Path>) accepts &str, String, PathBuf, and &Path without conversion at the call site. fn write_report(w: &mut impl Write) works with a file, a Vec<u8>, a network socket, or stdout, which also makes it trivially testable. This is dependency injection with no runtime cost and no framework.

Exercise

Define a trait Storage with save(&self, key: &str, value: &[u8]) -> Result<()> and load(&self, key: &str) -> Result<Vec<u8>>. Implement it for an in-memory HashMap and for the filesystem. Write a function generic over S: Storage that does a round-trip. Then change it to take &dyn Storage and note exactly what you gained and lost.

You've got it

…when you reach for a trait bound instead of an enum when you want to vary behavior.

Chapter 08

Collections, Iterators, and Closures

laziness · pull-based demand · the three loops · Fn / FnMut / FnOnce

The collection you'll use for nearly everything is Vec<T> — a growable heap array, contiguous, with amortized O(1) push. HashMap<K, V> is next, using SipHash by default, which is DoS-resistant but not the fastest; swap in rustc-hash or ahash when profiling says so. BTreeMap keeps keys sorted and supports range queries. VecDeque is a ring buffer for queue workloads. HashSet/BTreeSet are the map variants without values. BinaryHeap is a max-heap. Reaching for anything else is rare enough that when you do, you'll know why.

Fig 8.1

Which collection — by the operation you do most

pick an access pattern
Read this as — Vec wins more often than the complexity column suggests, because contiguous memory means the prefetcher works and a linear scan of 100 items beats a hash lookup. Reach for something else when the table says so and your N is large.

The entry API is the piece of HashMap most worth learning early, because it solves the "look up, and insert a default if absent" pattern in one hash lookup instead of two, while also avoiding a borrow conflict. The naive version — if !map.contains_key(k) { map.insert(k, v) } — hashes twice and reads worse.

*counts.entry(word).or_insert(0) += 1;
graph.entry(node).or_insert_with(Vec::new).push(edge);

Iterators are Rust's most-used abstraction and the trait is tiny: one required method, fn next(&mut self) -> Option<Self::Item>, returning None when exhausted. Everything else — map, filter, take, skip, zip, chain, enumerate, rev, flat_map, scan, peekable, windows, chunks — is a default method or an adapter struct built on that one function. Implementing Iterator for your own type gives you the entire adapter library for free.

Adapters are lazy. v.iter().map(expensive) does nothing until something consumes it — for, collect, sum, fold, count, find, any, all, min, max, for_each. The compiler unwraps the whole chain into a single loop with no intermediate allocation, which is the zero-cost claim in action.

Fig 8.2

Iterators are pull-based — demand travels backwards

pull one item at a time
map() calls — lazy0
if it were eager0
Read this as — the arrow of demand runs right to left: sum asks take, which asks map, which asks filter, which asks the source. Nothing is computed speculatively, so map never runs on items filter rejected, and take(3) stops the whole pipeline after three. The red bar is what an eager, list-building implementation would have done.

collect() is polymorphic over its return type, which is why you sometimes need to help it: let v: Vec<_> = iter.collect() or iter.collect::<Vec<_>>() — the "turbofish." It collects into Vec, HashMap, String, BTreeSet, and anything implementing FromIterator. Two tricks worth memorizing: collecting an iterator of Result<T, E> into Result<Vec<T>, E> short-circuits on the first error, and collecting into Result<(), E> runs an iterator purely for effects while propagating failure.

Fig 8.3

collect() into four different things

choose a target type
Read this as — the annotation on the left-hand side drives the entire behavior of the right-hand side. The Result case is the one to remember: it inverts the nesting from "a list of maybe-failures" to "maybe a list," and it stops at the first error rather than continuing.

The three borrowing forms of iteration are a constant source of early confusion, so hold them explicitly. for x in &v yields &T and leaves the vector usable. for x in &mut v yields &mut T for in-place mutation. for x in v consumes the vector and yields owned Ts, after which v is gone. The method forms are .iter(), .iter_mut(), and .into_iter(). When a compiler error says "value moved in previous iteration of loop," you almost always wanted &v.

Fig 8.4

Three ways to loop, three different outcomes

run each one
After the loop
Read this as — the third one is the trap. It reads identically to a Python for loop and it eats the vector. The compiler catches it immediately, which is the entire difference between this and a language where the bug survives to runtime.

Closures are anonymous functions that capture their environment, and Rust classifies them by how they capture into three traits. FnOnce consumes captured values and can be called once — anything that moves a captured value out. FnMut mutably borrows captures and can be called repeatedly. Fn only immutably borrows and can be called concurrently. The compiler infers the most permissive category automatically; the hierarchy matters when you're writing a function that takes a closure and must pick a bound.

Fig 8.5

Fn, FnMut, FnOnce — what the body does decides

click a closure body
implements does not
Read this as — the hierarchy nests: every Fn is also an FnMut and an FnOnce, because the ability to be called many times immutably implies the weaker abilities. When you write a function taking a closure, bound it with the weakest trait you actually need, since that accepts the most callers.

The move keyword forces a closure to take ownership of everything it captures rather than borrowing. You need it whenever the closure outlives the scope it was created in — spawning a thread, storing a callback in a struct, returning a closure from a function. Without move, the closure holds references into a stack frame that's about to disappear, and the compiler will tell you so. When you see "closure may outlive the current function," move is the answer roughly nine times in ten.

Returning closures requires either impl Fn(…) — static, single concrete type, cheapest — or Box<dyn Fn(…)>, needed when different branches return different closures. Storing closures in structs typically means Box<dyn Fn> plus a lifetime consideration. Function pointers (fn(i32) -> i32, lowercase) are a separate, simpler type that captures nothing and can be passed where a closure is expected.

Iterator chains are where Rust starts feeling like a high-level language, and the practical advice is to lean into that. Prefer .iter().filter().map().collect() over an index loop with a mutable accumulator; it's clearer, it's often faster because it dodges bounds checks, and it sidesteps borrow conflicts because the borrow is scoped to the chain. When a chain gets long enough to be unreadable, that's a signal to name the intermediate steps with let, not to fall back to indexing.

Exercise

Given a directory of text files, produce the top 20 words by frequency across all of them, excluding a stopword list, as a single iterator chain feeding a BinaryHeap or a sort. Then handle the case where a file fails to read by collecting into Result<Vec<_>, _> and observe the short-circuit.

You've got it

…when you write iterator chains by default and index loops only when you genuinely need the index.

Chapter 09

Smart Pointers and Shared State

Box · Rc · RefCell · Arc/Mutex · cycles · the arena alternative

Ownership as described so far handles tree-shaped data: one owner, clear lifetime, values nested inside values. Real programs sometimes need graphs, shared caches, or mutation through a shared handle. Smart pointers are the library types that provide these while preserving safety, and each one encodes a specific relaxation of the default rules with a specific cost.

Box<T> is the simplest: a single owner, heap-allocated. Three reasons to use it. Recursive types need it, since enum List { Cons(i32, List), Nil } has infinite size but Cons(i32, Box<List>) doesn't. Trait objects need it, since Box<dyn Error> gives an unsized type a known size. Very large values benefit from it when you want to avoid copying them around on the stack. Box is otherwise transparent — it derefs to T and costs one allocation.

Fig 9.1

Why a recursive enum needs a Box

toggle the box
Read this as — size is computed at compile time by recursion, and a type that contains itself has no fixed point. A Box is a pointer, and a pointer is 8 bytes regardless of what it points at, which terminates the recursion. That is the entire reason it's there.

Rc<T> is a reference-counted pointer for multiple ownership within a single thread. Cloning an Rc increments a counter and hands back another handle; the value drops when the count hits zero. The counter is a plain integer with no synchronization, which is why Rc is not Send and the compiler will stop you from moving one across threads. Use it when several parts of a data structure genuinely share a value and no single owner is natural.

Fig 9.2

Rc — watch the count

clone and drop handles
Handles on the stack
Heap — one allocation
Read this as — the strong count lives in the heap allocation, next to the value, not in the handles. That's why cloning is cheap and why the last handle to drop is the one that frees. Note that Rc gives you shared ownership but still only immutable access — for mutation you need the next figure.

RefCell<T> provides interior mutability: it lets you mutate through a shared reference by moving the borrow check from compile time to runtime. .borrow() and .borrow_mut() return guards; violating the aliasing rule panics instead of failing to compile. Rc<RefCell<T>> is the canonical combination for a shared, mutable, single-threaded value, and it shows up in tree and graph structures constantly. Cell<T> is the simpler cousin for Copy types — no guards, no panics, just get and set.

Fig 9.3

RefCell moves the check to runtime

try to break the rule again
RefCell<Vec<i32>>
value : Vec<i32>
Ref — shared guardRefMut — exclusive guardpanic at runtime
Read this as — the rule is identical to Chapter 3's. What changed is when it's enforced and what happens when you break it: a compile error becomes a runtime panic. That is a real downgrade, and it's the price of the flexibility. Reach for it when the compile-time proof is genuinely impossible, not when it's merely inconvenient.

The threaded equivalents are Arc<T> and Mutex<T>/RwLock<T>. Arc is Rc with atomic refcount operations, so it's Send+Sync and slightly slower. Mutex<T> wraps the data rather than sitting beside it, so you cannot access the contents without acquiring the lock — the type system enforces the discipline that in C is a comment. .lock() returns a Result because a mutex is poisoned if a thread panicked while holding it, which signals that the invariants may be broken.

Reference cycles are the one memory leak safe Rust permits. Two Rcs pointing at each other never reach a count of zero, and the memory is never freed. Weak<T> is the fix: a non-owning handle obtained via Rc::downgrade, upgraded back to an Option<Rc<T>> when you want to use it, returning None if the value is gone. The standard shape is parent-owns-children with Rc, children-point-to-parent with Weak.

Fig 9.4

The one leak safe Rust allows — and the fix

flip the back-edge to Weak
child → parent edge is
parent node
child node
Read this as — only strong counts keep a value alive. A Weak edge still lets the child reach the parent, but it doesn't vote on whether the parent lives, so the count can reach zero and the cycle collapses. Leaking memory is safe in Rust's sense of the word: it's not undefined behavior, just a bug.

Cow<'a, T> — clone-on-write — is the underrated one. It's an enum holding either a borrowed reference or an owned value, deferring allocation until someone actually mutates. A function that usually returns its input unchanged but occasionally modifies it should return Cow<str>: callers who pass clean input pay nothing, and only the modifying path allocates. Sanitizers, path normalizers, and escape functions are the typical users.

The judgment call is when reaching for these is right versus when it's an escape from a design problem. Rc<RefCell<T>> sprinkled through a codebase usually means the ownership structure was never decided, and the result is a program with the ergonomics of Rust and the aliasing hazards of C++, plus runtime panics. Before reaching for it, ask whether an arena with index-based references would work — a Vec<Node> where nodes reference each other by usize index is simpler, faster, more cache-friendly, and trivially serializable. This is how most production Rust graph code is written, and it's what the compiler itself does.

Fig 9.5

Rc<RefCell> graph vs. an index arena

walk the tree in both
Rc<RefCell<Node>> — pointer chasing
Vec<Node> arena — contiguous
cache lines touched0
arena — cache lines0
Read this as — the arena wins on three axes at once: no refcount traffic, no runtime borrow checks, and nodes that sit next to each other in memory. It loses on one — an index is not checked for validity the way a pointer is, so a stale index reads the wrong node instead of failing. Generational indices fix that when it matters.

The cost table is worth internalizing because these types are not interchangeable. Box: one allocation, no ongoing overhead. Rc: allocation plus non-atomic refcount updates on clone and drop. Arc: allocation plus atomic refcount updates, which are contended cache-line writes under load. RefCell: a runtime counter check per borrow, plus panic risk. Mutex: syscall on contention, atomics when uncontended. Choosing the weakest tool that works is a real performance decision, not a stylistic one.

Fig 9.6

Choose the weakest tool that works

answer three questions
Owners Mutation Threads
Read this as — most answers to these three questions land on plain T or Box<T>, which is the point. If you find yourself in the bottom-right cell often, the question to revisit is whether the data really needs many owners, or whether you reached for shared ownership to avoid deciding.
Exercise

Build a simple tree where each node has children and a link back to its parent. First with Rc/Weak/RefCell. Then rewrite it as a Vec<Node> arena with usize indices for parent and children. Compare line count, the number of .borrow() calls, and how each version behaves when you try to serialize it.

You've got it

…when you can explain why Rc<RefCell<T>> is safe and what it costs, and you reach for an arena first anyway.

Chapter 10

Modules, Crates, Testing, Tooling

the module tree · privacy · three kinds of test · the cargo ecosystem

A crate is the compilation unit — either a binary or a library. A package is what Cargo.toml describes and can contain one library plus any number of binaries. A workspace is a set of packages sharing a lockfile, a target directory, and dependency resolution, which is how you split a large project so that changing one crate doesn't rebuild the rest. Getting this vocabulary straight makes the documentation much easier to read.

Fig 10.1

Workspace, package, crate, module

click a level
Read this as — the boundary that matters for compile times is the crate. Everything inside one crate recompiles together, so a workspace of six small crates rebuilds far less on each edit than one crate with the same code in six modules.

Modules organize items within a crate and form a tree rooted at crate. mod foo; declares a module whose contents live in foo.rs or foo/mod.rs. Everything is private by default, including modules themselves, and privacy is per-module rather than per-type: a child module can see its ancestors' private items, but not vice versa. pub exposes an item to the parent, pub(crate) exposes it crate-wide, pub(super) to the parent only.

Fig 10.2

Who can see this item?

change the visibility
Visibility of fn hash_key
can call itcannot
Read this as — note that the child module can always see upward regardless of what you pick. Privacy in Rust points down the tree, not up, which is why a #[cfg(test)] mod tests block nested inside a file can test that file's private functions without any special mechanism.

use brings paths into scope and pub use re-exports, which is the key to designing a good public API. You can organize your source into deep module trees for your own convenience and then flatten the public surface in lib.rs with pub use crate::internal::deeply::Thing;, so consumers write mycrate::Thing. Almost every well-regarded crate does this. Internal structure and external surface are separate design problems.

Testing is built in and there's no framework to choose. #[test] on a function makes it a test; cargo test runs everything in parallel. Unit tests conventionally live in a #[cfg(test)] mod tests block at the bottom of the file they test, which gives them access to private items — a deliberate departure from the external-tests-only orthodoxy. Integration tests live in tests/ and see only your public API, which makes them a check on the API's usability.

Fig 10.3

Three kinds of test, three different jobs

click a kind
Read this as — the doc test is the one to adopt earliest and the one most people skip. It makes your examples uncompilable-if-wrong, which means the documentation cannot silently rot the way it does in every other ecosystem.

Documentation comments — /// for items, //! for the enclosing module — are Markdown, and code blocks inside them are compiled and run by cargo test. cargo doc --open generates and opens the rendered docs, including for every dependency you have, which is often faster than finding them online.

Cargo features are compile-time conditional compilation, declared in Cargo.toml and checked with #[cfg(feature = "foo")]. They let a crate offer optional functionality without forcing dependencies on everyone — serde support, async support, TLS backends. Two rules: features should be additive, since Cargo unifies features across the dependency graph and someone else may have enabled it, and default-features = false is how consumers opt out.

Dependency management follows semver strictly, and Cargo's resolution rules follow from it. serde = "1.0" means >=1.0.0, <2.0.0. Cargo.lock pins exact versions and should be committed for binaries; for libraries the convention has shifted toward committing it too, since it only affects your own CI. Cargo can link multiple major versions of the same crate simultaneously, which is why you occasionally see "expected struct rand::Rng, found struct rand::Rng" — two incompatible versions in one graph.

Fig 10.4

The tooling you should install once

click a tool
Read this as — cargo expand is the sleeper on this list. When a derive macro or a macro_rules! produces an error pointing at code you never wrote, expansion is the only way to see what the compiler is actually looking at.

Benchmarking has a specific pitfall: naive timing loops get optimized away, since the compiler can see the result is unused. Use criterion — statistical, stable, handles warmup and outlier detection — or the nightly #[bench] with std::hint::black_box to defeat the optimizer. Before optimizing anything, profile: perf on Linux, Instruments on macOS, cargo flamegraph cross-platform. Rust's performance intuitions are frequently wrong in both directions.

Publishing to crates.io takes about five minutes and is permanent — versions can be yanked but never deleted. Before publishing, set the metadata (description, license, repository, keywords), write a README that Cargo.toml points at, and consider whether your public API is one you want to support under semver. The Rust API Guidelines checklist is a genuinely useful document to read once, even if you never publish, because it encodes naming and design conventions that make your code legible to other Rust programmers.

Exercise

Take the CLI from Chapter 6 and restructure it as a workspace: a library crate with the core logic and a thin binary crate that handles argument parsing. Add unit tests to the library, an integration test in tests/, and doc examples on the three main public functions. Confirm cargo test runs all three kinds.

You've got it

…when you instinctively put logic in a library crate and keep main.rs under fifty lines.

Chapter 11

Concurrency

Send & Sync · scoped threads · channels · rayon · the async hill

The claim Rust makes about concurrency is "fearless," and the specific meaning is that data races are compile errors. Not a class of bug you mitigate through discipline — a category the language rejects. This falls out of Chapter 3 for free: a data race requires two threads accessing the same memory with at least one writing and no synchronization, and the aliasing-XOR-mutability rule already forbids exactly that pattern. The borrow checker was doing concurrency safety all along.

Fig 11.1

A data race, interleaved instruction by instruction

run it a few times
Protection
Interleaving
Shared counter
0
Read this as — with no protection, run it several times and watch the final value land on 4 or 5 instead of 6. The lost updates are real; this is what a data race is. Then note the third fact: Rust never compiles the unprotected version in the first place, so the run above is a simulation of a program you cannot write.

Two marker traits carry the guarantee across thread boundaries. Send means a type is safe to move to another thread. Sync means &T is safe to share, which is equivalent to &T being Send. Both are auto-derived: a type is Send if all its fields are. Rc is neither, because of its non-atomic refcount. RefCell is Send but not Sync, because of its non-atomic borrow flag. Raw pointers are neither. Everything else you're likely to write is both, silently, and you'll only notice when something isn't.

Fig 11.2

Send and Sync — and why Rc is neither

click a type
Read this as — Rc is the instructive row. Its refcount is a plain usize incremented non-atomically, so two threads cloning the same Rc could both read 2, both write 3, and lose a count — then free memory that's still in use. Making Rc !Send turns that entire failure mode into a compile error.

std::thread::spawn takes a 'static closure and returns a JoinHandle whose .join() yields the closure's return value as a Result, with an Err if the thread panicked. The 'static bound is why you usually need move — the spawned thread might outlive the current function, so it can't hold borrows into your stack frame. std::thread::scope lifts that restriction by guaranteeing all spawned threads join before the scope exits, which lets them borrow local data.

Fig 11.3

spawn needs 'static — scope doesn't

compare the two
Read this as — the bars show why spawn demands 'static: the thread's bar can extend past the function's bar, so any borrow it holds could outlive its referent. scope makes the thread's bar provably shorter, and the borrow becomes legal without any change to the borrow rules themselves.

Channels move data between threads by transferring ownership, which sidesteps sharing entirely. std::sync::mpsc gives you multi-producer, single-consumer; the crossbeam-channel crate gives you multi-consumer, select across channels, and better performance. Message passing is generally the design to prefer, because a pipeline of stages each owning its data is easier to reason about than shared state with locks, and Rust makes the ownership transfer explicit and free.

Fig 11.4

Share memory by communicating

send some messages
Producer thread
channel
Consumer thread
Read this as — the item is never in two places. send moves ownership into the channel and the producer's binding goes dead, exactly as in Fig 2.1. That's why channels need no locking on the payload: the type system already guarantees only one side can touch it.

When you do need shared state, it's Arc<Mutex<T>> from Chapter 9, with the lock scoped as tightly as you can manage. The guard releases on drop, so a lock held in a let binding lives until end of scope — introduce a block or call drop(guard) explicitly when you need it shorter. Deadlocks remain possible; Rust prevents data races, not lock-ordering mistakes. The standard defense is a consistent global lock ordering, or try_lock with backoff.

For data-parallel work, rayon is close to free performance. Change .iter() to .par_iter() and the work distributes across a thread pool with work stealing. The safety analysis is done by the type system — if your closure isn't Send, it won't compile — so the usual anxiety about parallelizing a loop mostly evaporates. Measure, since for small workloads the coordination overhead dominates.

Async is a separate hill and should be climbed after everything above is comfortable. Async functions return Futures, which are state machines that do nothing until polled by an executor. The standard library defines the Future trait and the async/await syntax but ships no executor, so you pick one: tokio dominates, with async-std and smol as alternatives. Mixing runtimes in one program is a known source of pain.

Fig 11.5

A future is a state machine that does nothing until polled

poll it yourself
Read this as — click poll() without making I/O ready and watch it return Pending forever. A future is inert; it makes progress only when an executor polls it, which is the concrete difference from a JavaScript promise that starts running the moment it's created. Every async surprise in Rust traces back to this.

The async concepts that actually cause difficulty are worth naming in advance. Futures are lazy — an un-awaited future never runs. Blocking calls inside async code stall the whole executor thread, so file I/O and CPU work need spawn_blocking. Cancellation happens by dropping a future, which can leave logical state half-finished at any await point; "cancellation safety" is a real property functions do or don't have. Async traits were only recently supported natively and still have restrictions around dyn. And async code tends to be viral: one async function pulls the calling stack into async.

Fig 11.6

Which concurrency tool — by workload

describe your workload
Bound by Concurrent tasks
Read this as — three of these four cells do not want async. Reaching for tokio on a program with four threads of work adds a runtime, a viral colouring of every function signature, and a new class of bug, in exchange for nothing you needed.
Exercise

Write a program that hashes every file in a directory tree. Version one: sequential. Version two: rayon's par_iter. Version three: a thread pool with a channel feeding work and another collecting results. Time all three on a real directory. Then try to introduce a data race deliberately and observe what the compiler says.

You've got it

…when you can explain why Rc isn't Send without looking it up.

Chapter 12

Unsafe, Macros, and the Road On

five superpowers · UB · FFI · expansion · where to go next

unsafe does not turn off the borrow checker. It enables exactly five additional capabilities: dereferencing a raw pointer, calling an unsafe function, implementing an unsafe trait, accessing a mutable static, and accessing a union field. Everything else — ownership, borrowing, lifetimes, type checking — applies unchanged inside an unsafe block. The keyword is narrower than its reputation suggests.

Fig 12.1

What unsafe turns on — and what it doesn't

flip the switch
Currently inside
The five superpowers
Still fully enforced
Read this as — the right-hand column never changes. Every borrow error you were getting in safe code you will still get inside an unsafe block, which is why "use unsafe to escape the borrow checker" is not a strategy. What changes is who is responsible for proving the absence of undefined behavior.

What unsafe actually means is a shift in who's responsible for upholding invariants. In safe Rust the compiler proves your program has no undefined behavior. In an unsafe block, you're asserting that you've verified it yourself. This is why unsafe blocks should be small, why they should be accompanied by a // SAFETY: comment stating the invariant and why it holds, and why the goal is always to wrap the unsafe operation in a safe abstraction that cannot be misused from outside.

Most Rust programmers write no unsafe for years, and that's the correct outcome. The legitimate uses are FFI, since the compiler cannot analyze C; implementing data structures the borrow checker can't verify, such as intrusive linked lists, custom allocators, and split_at_mut; hardware and OS interaction; and specific optimizations where you can prove a bounds check is redundant. Notably absent: "the borrow checker was annoying." Reaching for unsafe to escape a borrow error is how you get a program that crashes in production instead of at compile time.

Fig 12.2

The safe wrapper pattern — split_at_mut

drag the split point
Split at index
Read this as — two &mut into one slice is exactly what Chapter 3 forbids, and it is nonetheless sound here because the ranges are disjoint. The compiler can't prove disjointness from indices, so a human proves it once, inside four lines of unsafe, and every caller above gets a safe API. That inversion — unsafe core, safe surface — is the whole discipline.

Undefined behavior in Rust is stricter than in C in a few surprising ways: creating a &mut that aliases another live reference is UB even if you never use it, and producing an invalid value — a bool that isn't 0 or 1, a char outside the valid range, an uninitialized integer — is UB immediately. If you write unsafe, run your tests under Miri (cargo +nightly miri test), an interpreter that detects most UB at runtime. It's slow and it will find things you didn't expect.

FFI is the most common unsafe you'll actually meet. extern "C" declares foreign functions, #[repr(C)] gives your structs a stable layout, and bindgen generates the declarations from C headers automatically. The pattern is always the same: a thin unsafe layer that mirrors the C API exactly, then a safe idiomatic wrapper above it that handles ownership, converts error codes to Result, and ensures resources are released via Drop.

Macros are the other advanced topic and there are two kinds. Declarative macros (macro_rules!) are pattern-matching over syntax — you specify token patterns and expansions. They're how vec!, println!, and matches! work, they're moderately awkward to write, and they're the right tool for eliminating small repetitive patterns within a crate. Procedural macros are Rust programs that take a token stream and return one, live in their own crate type, and power #[derive(…)], attribute macros like #[tokio::main], and function-like macros such as sqlx::query!.

Fig 12.3

cargo expand — what the macro actually wrote

expand each one
What you write
What the compiler sees
Read this as — every macro is a source-to-source transform that happens before type checking. This is why macro errors point at code you didn't write, and why cargo expand is the only sane way to debug them.

Write macros sparingly. They're invisible to rust-analyzer's more useful features, they produce error messages pointing at generated code, and they make a codebase harder for newcomers. Before writing one, check whether a generic function, a trait with a blanket impl, or a builder would do — one of those usually would.

After Chapter 12 the productive move is to stop learning Rust deliberately and start using it while reading other people's. Pick two or three well-written crates in your interest area and read their source: ripgrep for CLI structure and performance work, serde for trait design at the limit, hyper for async architecture, clap for API ergonomics, crossbeam for concurrency primitives. Reading production Rust teaches idiom in a way tutorials can't, because tutorials show you features and real code shows you decisions.

Contributing is the accelerant. The Rust project labels issues E-easy and E-mentor, and most crate maintainers are welcoming to first-time contributors — documentation improvements and test coverage are always wanted and get you into the review process, which is where the highest-density feedback is. A code review from someone who's written Rust for five years is worth many hours of reading.

For continued depth, in rough order: the Rustonomicon for unsafe and UB, Rust for Rustaceans by Jon Gjengset as the best second book that exists, Gjengset's multi-hour live implementation streams, the Rust API Guidelines, and the Rust Blog and This Week in Rust for keeping current. The language moves on a six-week release train, so something you learn as impossible today may be routine in a year — async fn in traits and let … else were both recent, and both changed how ordinary code is written.

Exercise

Write a safe wrapper around a small C library — something like libz compression or an OS-specific syscall. Handle allocation and deallocation with Drop, convert error codes to a thiserror enum, and make the public API impossible to misuse. Run it under Miri. This exercises nearly every chapter simultaneously.

You've got it

…when you can read a crate's source and form an opinion about whether its API design was right.

Sequencing notes

The chapters are ordered by dependency, but the pace should be uneven. Chapters 2 through 4 deserve as much time as the rest combined — spend two weeks there if that's what it takes, because every later difficulty traces back to a shaky model of ownership. Chapters 5 through 8 tend to move fast, since they're the productive middle where Rust starts feeling good. Chapters 9 through 12 are reference material as much as curriculum; read them once, then return when a project actually demands them.

Build something real starting around Chapter 5, not at the end. A CLI tool is the standard first project because it avoids async, has clear boundaries, and produces something you'll use. Add a library crate around Chapter 10 and a concurrency layer around Chapter 11. Tutorials teach you what exists; only a project you care about will push you into the corners where the interesting learning is.

The two-week wall is real and predictable. Somewhere in the first fortnight, fighting the borrow checker on every function, you'll conclude the language is hostile. It resolves, usually abruptly, and the resolution is not that you learn workarounds but that you stop writing the code that provokes the errors. What felt like arbitrary restriction turns out to have been a description of how data actually flows through a program, and it's a description you carry back to every other language you write afterward.

Every hue means one thing
Owned & valid — this binding is the owner and can be used
&T shared — read-only borrow, many allowed at once
&mut T exclusive — one at a time, excludes all others
Moved or invalid — the compiler will reject any use
Dropped — destructor has run, memory released
Heap — an allocation somebody must own