Chapter 1: Rust Overview & Setup
Rust is a systems language that aims for "safety without sacrificing performance": no garbage collector, yet whole classes of memory bugs are eliminated at compile time. This chapter explains why Rust exists, what its core features are, and how to set up a working environment and run your first program.
Learning Objectives
- Understand Rust's design philosophy and core features.
- Install and manage the Rust toolchain with
rustup. - Create, build, and run projects with
cargo. - Understand the difference between debug and release builds.
1.1 Why Rust
Rust began at Mozilla (2006, public in 2010) with a goal: the performance and control of C++ without the memory bugs. Its three pillars are:
- Memory safety: ownership, borrowing, and lifetimes are checked at compile time, preventing null pointers, dangling references, buffer overflows, and data races — without a garbage collector or manual
free. - Zero-cost abstractions: high-level abstractions (iterators, generics, traits) compile down to code as fast as hand-written low-level code.
- Fearless concurrency: the same ownership rules also prevent data races at compile time, so you can write multithreaded code with confidence.
The trade-off is a learning curve: the borrow checker will at first "reject" your code, but what it rejects is real bugs. Once it clicks, the constraints become a reliable safety net for refactoring.
1.2 Installing the Toolchain
Rust is managed with rustup. On macOS/Linux:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Windows users download rustup-init.exe. After install, restart your shell and verify:
rustc --version
cargo --version
rustup lets you switch toolchains, add cross-compilation targets, and install components:
rustup update # update to latest stable
rustup component add clippy rustfmt
rustup target add wasm32-unknown-unknown # add a WebAssembly target
Tip: the
stablechannel is fine for everyday work. Trynightlyfor cutting-edge features, but don't depend on it in production.
1.3 Hello, Cargo
cargo is Rust's build tool and package manager; nearly every Rust project starts with it:
cargo new hello_rust
cd hello_rust
The layout it generates:
hello_rust/
├── Cargo.toml # project manifest (dependencies, metadata)
└── src/
└── main.rs # source entry point
src/main.rs by default:
fn main() { println!("Hello, world!"); }
Build and run:
cargo run
# prints: Hello, world!
cargo run compiles then runs. cargo build compiles without running; cargo check does only type checking without producing a binary — the fastest feedback loop during development.
1.4 Cargo Basics
Cargo.toml is the project manifest:
[package]
name = "hello_rust"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
edition: the language edition (2015/2018/2021). New projects use2021.[dependencies]: declares dependencies;cargofetches them from crates.io and pins them inCargo.lock.
Common commands:
| Command | Purpose |
|---|---|
cargo new <name> | new binary project |
cargo new --lib <name> | new library project |
cargo build | compile (debug build) |
cargo build --release | optimized build, for release/benchmarks |
cargo run | compile and run |
cargo check | type-check only (fastest) |
cargo test | run all tests |
cargo fmt | format code |
cargo clippy | run lints |
cargo doc --open | generate and open docs |
Debug vs release: the default
cargo buildis a debug build (opt-level = 0, fast to compile, includes debug info). For benchmarks or deployment you must use--release, or the results are not representative.
1.5 A Slightly Bigger Example
A taste of Rust's style — explicit types, expression semantics, zero-cost abstraction:
fn main() { let numbers = vec![1, 2, 3, 4, 5, 6]; // Iterator combinators: filter evens, double, sum let result: i32 = numbers .iter() .filter(|&&n| n % 2 == 0) .map(|&n| n * 2) .sum(); println!("sum of doubled evens = {result}"); // 4 + 8 + 12 = 24 }
This reads like a math formula yet compiles to the same machine code as a hand-written loop. That is "zero-cost abstraction" made concrete — later chapters unpack each mechanism.
1.6 Toolchain & Ecosystem
- rust-analyzer: the IDE backend that powers VS Code / Vim / Emacs with completion, jump-to-definition, inline types. Install it and the dev experience transforms.
- rustfmt: the official formatter; ends style debates.
- clippy: the linter; catches a long list of common mistakes.
- crates.io: the package registry.
cargo add <crate>adds a dependency. - docs.rs: auto-generated docs for every crate published to crates.io.
1.7 Summary
Rust guarantees memory safety and concurrency safety at compile time via ownership, and offers zero-cost abstractions so high-level code does not sacrifice performance. rustup manages the toolchain, cargo manages projects and dependencies, and cargo check/run/test are the daily trio. Add rust-analyzer, rustfmt, and clippy, and you have a capable environment.
Exercises
- Create a project with
cargo new, write a function returning the first N Fibonacci numbers, and verify withcargo runandcargo test. - Add a dependency (e.g.
rand) and inspect the generated docs withcargo doc --open. - Write code that triggers a
clippywarning (e.g. a needlessreturn), runcargo clippy, and fix it.
Chapter 2: Variables, Data Types & Control Flow
This chapter is the grammar of Rust: how to declare variables, what data types exist, and how to organize logic with control flow and functions. These are the bedrock for every later chapter — especially the "immutable by default" decision, which runs through every line of Rust you will write.
Learning Objectives
- Declare variables with
let, understandmutvs immutable, and shadowing. - Master scalar types (integers, floats, booleans, chars) and compound types (tuples, arrays).
- Understand strings: the difference between
Stringand&str. - Use
if/loop/while/forand pattern matching for control flow. - Define functions and understand expression semantics and return values.
2.1 Variables & Mutability
Rust declares variables with let, immutable by default:
fn main() { let x = 5; // x = 6; // error: x is immutable println!("{x}"); let mut y = 5; y = 6; // OK: y declared mut println!("{y}"); }
Immutability by default is deliberate: it makes code predictable and lets the compiler optimize more. When you do need to change a value, write mut explicitly — a signal that "state changes here."
Shadowing
You can re-declare a variable with the same name; the new one shadows the old. Shadowing can even change the type:
fn main() { let x = 5; let x = x + 1; // compute from the old value let x = x * 2; // {x} = 12 let spaces = " "; // &str let spaces = spaces.len(); // usize — type changed too println!("{x} {spaces}"); }
mutvs shadowing:mutchanges the same variable's value and cannot change its type; shadowing creates a new variable and can change the type. Turning a string into its length is natural with shadowing and impossible withmut.
Constants
const differs from an immutable variable: it is evaluated at compile time, requires a type annotation, is uppercase, and can be declared in any scope:
#![allow(unused)] fn main() { const MAX_POINTS: u32 = 100_000; }
2.2 Scalar Types
| Type | Meaning | Example |
|---|---|---|
i8…i128, isize | signed integer | -5, 42 |
u8…u128, usize | unsigned integer | 0, 255 |
f32, f64 | float | 3.14, 2.0 |
bool | boolean | true, false |
char | Unicode scalar value (4 bytes) | 'A', '中', '🦀' |
fn main() { let a: i32 = -42; let b: u64 = 1_000_000; // underscores for readability let c: f64 = 2.71828; let flag: bool = true; let heart: char = '🦀'; println!("{a} {b} {c} {flag} {heart}"); }
Integer literals:
42defaults toi32. Annotate when the context needs another type:let n: u8 = 42;. Integer overflow panics in debug builds and wraps in release — usechecked_*,wrapping_*, orsaturating_*methods to handle it explicitly when it matters.
2.3 Compound Types: Tuples & Arrays
A tuple groups values of different types, fixed length:
fn main() { let tup: (i32, f64, &str) = (500, 6.4, "hello"); let (x, _, s) = tup; // destructure println!("{x} {s}"); println!("{}", tup.0); // index access }
An array is fixed-length, same-type, contiguous on the stack:
fn main() { let arr = [1, 2, 3, 4, 5]; let zeros = [0; 10]; // ten 0s println!("first = {}, len = {}", arr[0], arr.len()); // Out-of-bounds access panics at runtime (debug build) — // it does not read past the end like C would. // let oob = arr[10]; // panic }
Arrays vs
Vec: arrays have a compile-time-fixed length and suit small, known collections; for runtime-growable data useVec(Chapter 7).
2.4 Strings: String vs &str
Rust has two string types that trip up beginners:
&str: a string slice — a borrow of UTF-8 bytes somewhere. A literal"hello"is a&'static str.String: heap-allocated, growable, owned.
fn main() { let literal: &str = "hello"; // borrowed, immutable let mut owned = String::from("hello"); // heap, growable owned.push_str(", world"); owned.push('!'); // Conversions let from_slice: String = literal.to_string(); let to_slice: &str = &owned; println!("{owned} {from_slice} {to_slice}"); }
Rule of thumb: prefer &str for function parameters (accepts both &str and &String); use String when you need to own, mutate, or return it.
2.5 Control Flow
if is an expression
if yields a value; all branches must have the same type:
fn main() { let n = 7; let label = if n % 2 == 0 { "even" } else { "odd" }; println!("{label}"); if n > 10 { println!("big"); } else if n > 3 { println!("medium"); } else { println!("small"); } }
Loops: loop, while, for
fn main() { // loop: infinite loop, break can return a value let mut count = 0; let result = loop { count += 1; if count == 10 { break count * 2; } }; println!("{result}"); // 20 // while: conditional loop let mut n = 3; while n > 0 { n -= 1; } // for: iterate a collection — the most common for x in [1, 2, 3] { println!("{x}"); } for i in 0..5 { print!("{i} "); } // 0 1 2 3 4 for i in (1..=3).rev() { print!("{i} "); } // 3 2 1 }
Ranges come as a..b (half-open) and a..=b (inclusive). Indexed while loops are rare in Rust — iterators are safer and clearer.
2.6 Functions
Functions are defined with fn; parameters need type annotations. Rust is an expression language: without return, the last expression (no semicolon) is the return value:
fn add(a: i32, b: i32) -> i32 { a + b // expression — the return value } fn greet(name: &str) { // no -> means returns the unit type () println!("hi, {name}"); } fn abs(x: i32) -> i32 { if x < 0 { -x } else { x } // an if expression as the return value } fn main() { greet("alice"); println!("{} {}", add(2, 3), abs(-7)); }
Statements vs expressions:
let x = 5;is a statement (no value);x + 1is an expression (has a value). Adding a semicolon turns an expression into a statement — and drops its value. The common "missing return value" error is usually a stray semicolon.
Diverging functions
Functions that never return are typed -> !:
#![allow(unused)] fn main() { fn forever() -> ! { loop {} } }
2.7 Summary
Rust variables are immutable by default; use mut when you need to change them, and shadowing to reuse a name or even change its type. Scalars and compound types are the foundation; for strings, distinguish owned String from borrowed &str. if and loop are expressions, and a function returns its last semicolon-free expression. These rules are simple yet underpin every later topic — ownership, generics, error handling.
Exercises
- Write
fn fizzbuzz(n: u32)that prints 1 to n by the classic FizzBuzz rules. - Return both quotient and remainder from one function:
fn divmod(a: i32, b: i32) -> (i32, i32). - Sum the integers 1 to 100 with a
forand a range, and note why an indexedwhileis unnecessary.
Chapter 3: Ownership & Borrowing
Ownership is Rust's most distinctive feature and the foundation of its memory safety without a garbage collector. This chapter covers the three ownership rules, borrowing and the borrow checker, lifetimes, and slices. Once you understand it, you can read the compiler's errors and see why it asks what it asks.
Learning Objectives
- Master the three rules of ownership and move semantics.
- Understand borrowing: shared references
&Tand mutable references&mut T. - Know the borrowing rules and the "many references at once" restriction.
- Use lifetime annotations to make reference relationships explicit.
- Use slices
&[T]/&strto borrow a span of contiguous data.
3.1 The Three Rules of Ownership
Rust's memory management rests on three rules:
- Each value has a single owner — a variable.
- When the owner goes out of scope, the value is dropped (its destructor runs, memory is freed).
- Assignment or passing to a function moves ownership — unless the type implements
Copy.
fn main() { { let s = String::from("hello"); // s owns it println!("{s}"); } // s goes out of scope; the String's memory is freed — no free needed let s1 = String::from("hello"); let s2 = s1; // ownership moves from s1 to s2 // println!("{s1}"); // error: s1 was moved, no longer valid println!("{s2}"); }
Move vs Copy
String owns heap memory, so assignment is a move — the old variable is invalidated, avoiding a double free. Stack types (integers, booleans, chars, fixed-size arrays) implement the Copy trait, so assignment is a bitwise copy and the old variable stays usable:
fn main() { let a = 5; let b = a; // i32 is Copy; a still usable println!("{a} {b}"); let s1 = String::from("hi"); let s2 = s1; // String is not Copy; s1 is moved // println!("{s1}"); // error }
Passing to a function is a move too: after you pass a
Stringto a function, the caller can no longer use it. To "lend without transferring ownership," use references (next section).
3.2 Borrowing & References
Borrowing lets a function use a value without taking ownership. &T is a shared reference (read-only); &mut T is a mutable reference:
fn calculate_length(s: &String) -> usize { s.len() } // s is a borrow; nothing is freed here fn append(s: &mut String) { s.push_str("!"); } fn main() { let mut s = String::from("hello"); let len = calculate_length(&s); // borrow; s still owned by main println!("{s} length {len}"); append(&mut s); println!("{s}"); // hello! }
The Two Borrowing Rules
The borrow checker enforces two rules at compile time:
- At any moment, you may have either several shared references
&T, or exactly one mutable reference&mut T— not both. - References must always be valid (never dangling).
#![allow(unused)] fn main() { let mut s = String::from("hello"); let r1 = &s; let r2 = &s; // OK: several shared references // let r3 = &mut s; // error: cannot borrow mutably while shared refs exist println!("{r1} {r2}"); let mut s = String::from("hi"); let r1 = &mut s; // let r2 = &mut s; // error: only one mutable reference at a time println!("{r1}"); }
Why so strict? Mixing mutable references is exactly what causes data races and iterator invalidation. Rejecting them at compile time eliminates a whole class of concurrency bugs up front.
NLL: Non-Lexical Lifetimes
The modern borrow checker (NLL) looks at where a reference is actually last used, not the end of its scope:
#![allow(unused)] fn main() { let mut s = String::from("hello"); let r1 = &s; let r2 = &s; println!("{r1} {r2}"); // r1, r2 are no longer used after this point let r3 = &mut s; // OK: the old shared refs are no longer needed println!("{r3}"); }
3.3 Dangling References
A function cannot return a reference to a local variable — the variable is freed when the function returns, leaving the reference dangling. The compiler rejects it:
#![allow(unused)] fn main() { // fn dangle() -> &String { // let s = String::from("hi"); // &s // } // error: s is freed here; the returned reference would dangle // Correct: return the String, transferring ownership fn no_dangle() -> String { let s = String::from("hi"); s } }
3.4 Lifetimes
When references come from several places and the compiler cannot infer which lives longest, lifetime annotations spell out the relationship. They do not change how long a reference lives; they only declare constraints.
// 'a means: the returned reference lives at least as long as the shorter of x and y fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y } } fn main() { let s1 = String::from("long string"); let s2 = String::from("hi"); let result = longest(s1.as_str(), s2.as_str()); println!("longer: {result}"); }
Lifetime elision
Most of the time you need not write annotations. The compiler applies three elision rules automatically:
- Each reference parameter gets its own lifetime.
- If there is exactly one input lifetime, it is assigned to all output references.
- If there is a
&self/&mut self,self's lifetime is assigned to all output references.
When these do not apply, the compiler errors and asks you to annotate explicitly — usually a sign your API needs rethinking.
Lifetimes in structs
A struct holding a reference must annotate it:
struct Excerpt<'a> { part: &'a str, } fn main() { let novel = String::from("call me Ishmael. some years ago..."); let first = novel.split('.').next().unwrap(); let e = Excerpt { part: first }; println!("{:?}", e.part); }
'a says an Excerpt cannot outlive the string it borrows.
3.5 Slices
A slice is a borrow of a contiguous sequence, without ownership. &[T] is a slice of an array/Vec; &str is a string slice:
fn first_word(s: &str) -> &str { let bytes = s.as_bytes(); for (i, &b) in bytes.iter().enumerate() { if b == b' ' { return &s[..i]; } } &s[..] } fn main() { let s = String::from("hello world"); let word = first_word(&s); // word borrows s println!("{word}"); // If you mutated s here, the borrow checker would reject it because word is still alive. }
Slices let one function work for &String, &str, &[T], and &Vec<T> — a key to writing general Rust code.
3.6 Putting It Together
Tying ownership, borrowing, and slices together — a function that finds the longest line with no allocation:
fn longest_line<'a>(lines: &'a [&'a str]) -> Option<&'a str> { lines.iter().copied().max_by_key(|l| l.len()) } fn main() { let text = ["short", "a longer line", "mid"]; if let Some(longest) = longest_line(&text) { println!("longest: {longest}"); } }
longest_line only borrows the slice and returns a borrow — no heap allocation at all. That is zero-cost abstraction in action.
3.7 Summary
The three ownership rules, move semantics, the two borrowing rules, lifetime annotations, and slices form the skeleton of Rust's memory safety. The borrow checker is strict, but it eliminates null pointers, dangling references, double frees, and data races. It rejects not your intent but the bugs hiding in your code. Master this chapter and you have crossed Rust's steepest hill.
Exercises
- Explain why
let s2 = s1;(s1isString) invalidatess1, whilelet b = a;(aisi32) leavesausable. - Write
fn longest_word(s: &str) -> &strreturning the first longest word (split on spaces). Annotate any lifetimes needed and observe the elision rules. - Write a struct
Config<'a>holding a&strand construct an instance; verify it cannot outlive theStringit borrows.
Chapter 4: Structs & Enums
Real-world data is rarely isolated. Rust uses structs to bundle related fields into a custom type and enums to express "a value may be one of several forms." Paired with pattern matching, these let you model a domain precisely — and make illegal states unrepresentable at compile time.
Learning Objectives
- Define structs, methods, and associated functions.
- Model "one-of" data with enums, and understand
OptionandResultas enums. - Use
matchandif letfor pattern matching. - Attach behavior to types with
implblocks.
4.1 Structs
A struct groups named fields into a type. Three forms: named-field, tuple, and unit:
// Named-field — most common struct User { name: String, age: u32, active: bool, } // Tuple struct — fields unnamed, lightweight wrapper struct Color(i32, i32, i32); // Unit struct — no fields, often used with traits struct Marker; fn main() { let u = User { name: "alice".into(), age: 30, active: true }; println!("{} {} {}", u.name, u.age, u.active); let c = Color(255, 128, 0); println!("{} {} {}", c.0, c.1, c.2); }
Field privacy: struct fields are private by default; accessing them outside their module requires
pub(Chapter 8).
Field shorthand & update syntax
When a variable name matches a field name you can shorthand; .. copies the rest:
fn main() { let name = String::from("bob"); let u1 = User { name, age: 25, active: true }; // name shorthand let u2 = User { age: 26, ..u1 }; // rest copied from u1 println!("{} {}", u2.name, u2.age); }
Note:
..u1moves fields out ofu1. Sinceu1.nameis moved,u1as a whole is no longer usable (unless all copied fields areCopy).
4.2 Methods & Associated Functions: impl
Use impl to attach behavior. An fn with &self/&mut self/self is a method; without self it is an associated function (like a static method):
struct Rectangle { width: f64, height: f64, } impl Rectangle { // Associated function — a constructor, like Rectangle::new fn new(width: f64, height: f64) -> Self { Rectangle { width, height } } // Method — borrows self fn area(&self) -> f64 { self.width * self.height } // Method — mutable borrow fn scale(&mut self, factor: f64) { self.width *= factor; self.height *= factor; } } fn main() { let mut r = Rectangle::new(3.0, 4.0); println!("area = {}", r.area()); // 12 r.scale(2.0); println!("area = {}", r.area()); // 48 }
Self is an alias for the current type. You may write multiple impl blocks, often to group methods by concern.
4.3 Enums: One-of Values
An enum represents a value that may be one of several variants. Rust's enums are powerful — each variant can carry different types and amounts of data:
enum Message { Quit, // no data Move { x: i32, y: i32 }, // named fields Write(String), // one value ChangeColor(i32, i32, i32), // tuple } fn main() { let m = Message::Write("hello".into()); process(m); } fn process(msg: Message) { // Must handle every variant — the compiler enforces exhaustiveness match msg { Message::Quit => println!("quit"), Message::Move { x, y } => println!("move to {x},{y}"), Message::Write(text) => println!("write: {text}"), Message::ChangeColor(r, g, b) => println!("color {r},{g},{b}"), } }
Enum vs struct: use an enum when a value "is A or B or C"; use a struct when it "has A and B and C."
Option<T>: a standard-library enum
Option uses an enum to express "a value or nothing," replacing null:
#![allow(unused)] fn main() { enum Option<T> { Some(T), None, } }
There is no null in Rust — to represent possible absence, use Option<T>, and the compiler forces you to handle None. Chapter 6 expands on its role in error handling.
4.4 Pattern Matching: match
match does exhaustive branching on an enum — one of Rust's most powerful control-flow constructs:
fn describe(n: i32) -> &'static str { match n { 0 => "zero", 1..=9 => "single digit", 10 | 20 | 30 => "round ten", _ if n < 0 => "negative", // guard _ => "other", } } fn main() { println!("{}", describe(0)); println!("{}", describe(7)); println!("{}", describe(-3)); }
Key points:
- Must be exhaustive;
_is the catch-all. - Arms can bind variables (e.g.
Message::Move { x, y }bindsx/yto the field values). - You can add a guard (
if condition) for extra filtering.
if let: caring about one arm
When you want to handle only one case and ignore the rest, if let is more concise than match:
fn main() { let m = Message::Write("hi".into()); if let Message::Write(text) = m { println!("writing: {text}"); } else { println!("not Write"); } }
while let works similarly, for repeated deconstruction in a loop.
4.5 Worked Example: A State Machine
Model an order state machine with an enum and pattern matching — an illegal transition cannot even be written:
enum OrderState { Pending, Paid, Shipped, Delivered, Cancelled, } impl OrderState { fn next(self) -> OrderState { match self { OrderState::Pending => OrderState::Paid, OrderState::Paid => OrderState::Shipped, OrderState::Shipped => OrderState::Delivered, // Delivered or Cancelled — no next state OrderState::Delivered | OrderState::Cancelled => self, } } fn label(&self) -> &'static str { match self { OrderState::Pending => "pending", OrderState::Paid => "paid", OrderState::Shipped => "shipped", OrderState::Delivered => "delivered", OrderState::Cancelled => "cancelled", } } } fn main() { let mut s = OrderState::Pending; for _ in 0..4 { println!("{}", s.label()); s = s.next(); } }
This shows the core value of enums: encode business rules into the type system, so "delivered then back to pending" cannot be expressed at all.
4.6 Summary
Structs bundle related fields into custom types; enums express "one-of" values; impl blocks attach behavior; match does exhaustive branching. Option replaces null and forces you to handle absence. Used together, they encode business constraints into types — making illegal states unrepresentable, which is the heart of Rust's type-safe design.
Exercises
- Define a
Pointstruct and aShapeenum (Circle,Rectangle,Triangle); usematchto compute each shape's area. - Add a
birthday(&mut self)method toUserthat incrementsage, and an associated functionUser::new(name, age). - Write a function returning the first positive number from a list as
Option<i32>, and handle the result withif let.
Chapter 5: Generics & Traits
Generics and traits are Rust's most important abstraction mechanisms: generics let you write one piece of code for many types, and traits define what a type can do. Together they yield code that is both flexible and type-safe — and, thanks to monomorphization, with zero runtime overhead.
Learning Objectives
- Write type-agnostic code with generic functions and structs.
- Define and implement traits, and understand default methods.
- Constrain generic types with trait bounds.
- Distinguish static dispatch (generics) from dynamic dispatch (trait objects).
- Design interfaces that fit a domain using associated types.
5.1 Generics
A generic uses a placeholder type T in place of a concrete type, filled in at the call site. A single largest works for any slice of comparable items:
fn largest<T: PartialOrd>(list: &[T]) -> &T { let mut biggest = &list[0]; for item in &list[1..] { if item > biggest { biggest = item; } } biggest } fn main() { let nums = vec![3, 1, 4, 1, 5, 9, 2, 6]; println!("largest: {}", largest(&nums)); let chars = vec!['a', 'z', 'm']; println!("largest: {}", largest(&chars)); }
Generic structs & enums
struct Pair<T> { first: T, second: T, } impl<T> Pair<T> { fn new(first: T, second: T) -> Self { Pair { first, second } } } fn main() { let p = Pair::new(1, 2); println!("{} {}", p.first, p.second); }
Option<T>, Result<T, E>, and Vec<T> are themselves generic enums/structs.
Zero cost: generics are monomorphized at compile time — the compiler generates a dedicated copy for each concrete type.
largest::<i32>andlargest::<char>are two separate functions, each inlinable, with no dispatch overhead at runtime.
5.2 Traits: What a Type Can Do
A trait defines a set of method signatures; a type provides an implementation with impl, declaring "I can do these things":
trait Summary { fn summarize(&self) -> String; // Default method — implementers may skip it fn preview(&self) -> String { let s = self.summarize(); let n = s.len().min(20); format!("{}...", &s[..n]) } } struct Article { title: String, content: String, } impl Summary for Article { fn summarize(&self) -> String { format!("{}: {}", self.title, self.content) } } fn main() { let a = Article { title: "Rust released".into(), content: "Rust 2021 edition is stable".into(), }; println!("{}", a.summarize()); println!("{}", a.preview()); // default implementation }
Traits can have default implementations that implementers override as needed.
5.3 Trait Bounds: Constraining Generics
A generic T can do almost nothing by default. To call its methods, declare the traits it must implement with a trait bound:
#![allow(unused)] fn main() { // T: Summary + Display — T must implement both fn report<T: Summary>(item: &T) { println!("report: {}", item.summarize()); } }
where clauses
With many bounds, a where clause is clearer:
#![allow(unused)] fn main() { fn merge<T, U>(a: &T, b: &U) -> String where T: Summary, U: Summary, { format!("{} | {}", a.summarize(), b.summarize()) } }
impl Trait syntax
Parameters and return values can use impl Trait as shorthand:
#![allow(unused)] fn main() { // Parameter: accept any type implementing Summary fn report(item: &impl Summary) { /* ... */ } // Return: return some type implementing Summary (caller need not know which) fn make() -> impl Summary { Article { title: "x".into(), content: "y".into() } } }
Returning
impl Trait: you may return only a single concrete type. To return one of several types, use a trait object (next section).
5.4 Static vs Dynamic Dispatch
Generics with trait bounds are static dispatch: monomorphized at compile time, one copy per concrete type, calls are direct and inlinable. The trade-off is slightly larger binaries.
When you need to hold values of "several different types" at runtime (e.g. a Vec of various Summary), use dynamic dispatch — a trait object:
fn main() { // &dyn Summary is a trait object: dispatched via a vtable at runtime let items: Vec<Box<dyn Summary>> = vec![ Box::new(Article { title: "a".into(), content: "b".into() }), ]; for it in &items { println!("{}", it.summarize()); } }
| Form | Dispatch | Overhead | Holds many types? |
|---|---|---|---|
Generic T: Trait | static (monomorphized) | none | no (one per type) |
&dyn Trait / Box<dyn Trait> | dynamic (vtable) | one indirect call | yes |
Rule of thumb: prefer generics when you can (faster); reach for dyn only when you need runtime polymorphism.
5.5 Associated Types
An associated type lets a trait carry a "type decided by the implementer," which often fits a domain better than a generic parameter. Iterator is the classic example:
trait Iterator { type Item; // associated type fn next(&mut self) -> Option<Self::Item>; } struct Counter { count: u32 } impl Iterator for Counter { type Item = u32; // Counter yields u32 fn next(&mut self) -> Option<u32> { self.count += 1; if self.count <= 5 { Some(self.count) } else { None } } } fn main() { for n in Counter { count: 0 } { println!("{n}"); } }
The difference from a generic parameter: a type can have only one impl of a trait with an associated type (the type is fixed), whereas a generic trait can have several impls (one per set of type parameters). Iterator uses an associated type because "what an iterator yields" is fixed for a given iterator.
5.6 Summary
Generics write type-agnostic code that is monomorphized and zero-cost at compile time; traits define "what a type can do" and constrain generics via trait bounds. Static dispatch (generics) is fast but cannot be polymorphic at runtime; dynamic dispatch (dyn Trait) is flexible but has a vtable cost. Associated types make a trait's interface fit the domain. Together these are how Rust abstracts without losing performance.
Exercises
- Write a generic
fn first<T>(v: &[T]) -> Option<&T>returning the first element of a slice. - Define a
Drawabletrait (fn draw(&self)), implement it for two different structs, and hold them in aVec<Box<dyn Drawable>>to iterate. - Add a
take_nmethod to theCounterabove (returningimpl Iterator) and observe how the associated type propagates.
Chapter 6: Error Handling
Error handling is where Rust diverges most sharply from mainstream languages — and where it earns the reputation of being "fearless." Rust does not throw exceptions. Instead, the type system makes the possibility of failure visible in function signatures, so the compiler forces you to decide what happens when something goes wrong. The payoff is dramatic: a large class of crashes that silently happen at runtime in other languages simply cannot occur in shipped Rust code.
This chapter teaches you to think about errors the Rust way: divide them into recoverable and unrecoverable, model recoverable failures with Result, propagate them cleanly with ?, and design error types that scale from a small script to a large library.
Learning Objectives
- Distinguish recoverable errors (
Result) from unrecoverable ones (panic!). - Use
Option<T>to represent the absence of a value. - Use
Result<T, E>and pattern matching to handle expected failures. - Propagate errors concisely with the
?operator. - Convert between error types with the
Fromtrait. - Design custom error types with
thiserror, and chooseanyhowfor applications. - Apply error-handling best practices in
asynccode.
6.1 Two Kinds of Errors
Rust groups errors into two families. The distinction is the foundation of everything in this chapter.
| Kind | Type | Meaning | Example |
|---|---|---|---|
| Unrecoverable | panic! | A bug or a broken invariant — the program cannot safely continue. | Index out of bounds, dividing by zero, a Mutex that was poisoned. |
| Recoverable | Result<T, E> | An expected failure that the caller can react to. | File not found, network timeout, malformed input. |
Mental model. A panic is the program saying "something is wrong that I cannot fix; stop now." A Result is a function saying "this might fail — here is the value, or here is the reason it failed; you decide." Most real-world failures are recoverable, so most of your error handling will use Result.
6.1.1 panic! — when something is truly wrong
A panic unwinds the stack (or aborts) and ends the current thread. Use it for conditions that should never happen in correct code.
fn main() { let numbers = [10, 20, 30]; // Index 5 is out of bounds — a logic bug, so Rust panics. let value = numbers[5]; println!("{value}"); }
You can trigger a panic explicitly with the panic! macro, and attach a message:
#![allow(unused)] fn main() { fn assert_non_empty<T>(slice: &[T]) { if slice.is_empty() { panic!("expected a non-empty slice, got an empty one"); } } }
unwrap() and expect() are shortcuts that panic on failure. They are excellent for quick scripts and tests, but risky in production paths because they turn a recoverable failure into a crash.
fn main() { // `parse` returns Result; `unwrap` panics if parsing fails. let n: i32 = "42".parse().unwrap(); // `expect` lets you attach context — prefer it over bare `unwrap`. let m: i32 = "abc".parse().expect("input must be a valid integer"); println!("{n} {m}"); }
Rule of thumb.
unwrap()/expect()are fine in prototypes and tests. In code that handles user input or external systems, reach for?and properResulthandling instead.
6.2 Option<T> — the absence of a value
Before errors, consider absence. When a function can legitimately return "nothing" (not a failure, just no value), use Option<T>.
// The signature itself tells you: this might not find anything. fn find_user(users: &[&str], name: &str) -> Option<&str> { for u in users { if *u == name { return Some(u); } } None } fn main() { let users = ["alice", "bob", "carol"]; match find_user(&users, "bob") { Some(name) => println!("found {name}"), None => println!("no such user"), } // Convenience combinators — concise and null-safe. let upper = find_user(&users, "alice").map(str::to_uppercase); println!("{upper:?}"); // Some("ALICE") // Provide a default when the value is missing. let display = find_user(&users, "zoe").unwrap_or("guest"); println!("{display}"); // guest }
Key Option combinators:
| Combinator | Returns | Purpose |
|---|---|---|
map(f) | Option<U> | Transform the inner value if present. |
and_then(f) | Option<U> | Chain operations that themselves return Option. |
unwrap_or(v) | T | Fall back to v when None. |
unwrap_or_default() | T | Fall back to T::default(). |
is_some() / is_none() | bool | Inspect without consuming. |
Prefer combinators over nested match — they express intent more clearly.
6.3 Result<T, E> — recoverable failures
Result is the workhorse of Rust error handling. It is just an enum:
#![allow(unused)] fn main() { enum Result<T, E> { Ok(T), Err(E), } }
A function that can fail returns Result instead of panicking. The classic example is reading a file:
use std::fs; use std::io; fn read_config(path: &str) -> Result<String, io::Error> { fs::read_to_string(path) // already returns Result<String, io::Error> } fn main() { match read_config("config.toml") { Ok(contents) => println!("config loaded:\n{contents}"), Err(error) => eprintln!("failed to read config: {error}"), } }
The error type io::Error is concrete and informative. Pattern matching lets you branch on the kind of failure:
use std::io; use std::fs; fn main() { match fs::read_to_string("missing.txt") { Ok(_) => println!("read ok"), Err(error) => match error.kind() { io::ErrorKind::NotFound => eprintln!("file does not exist"), io::ErrorKind::PermissionDenied => eprintln!("no permission"), _ => eprintln!("other io error: {error}"), }, } }
Result shares most combinators with Option — map, and_then (also called ?-style chaining), unwrap_or, etc.
6.4 The ? operator — clean propagation
Handling every error with match gets verbose. The ? operator is the idiomatic way to propagate an error: "if this succeeded, keep going; if it failed, return the error to the caller immediately."
#![allow(unused)] fn main() { use std::fs; use std::io; // `?` turns a verbose match into a one-liner. fn read_config(path: &str) -> Result<String, io::Error> { let contents = fs::read_to_string(path)?; // propagate on error Ok(contents.trim().to_string()) } }
? works on both Result and Option. You can even convert between them with the right context.
Chaining multiple fallible operations
This is where ? shines — a pipeline of fallible steps reads like straight-line code:
#![allow(unused)] fn main() { use std::fs; use std::io; fn load_and_parse(path: &str) -> Result<i32, io::Error> { let text = fs::read_to_string(path)?; // io::Error let trimmed = text.trim(); let value: i32 = trimmed.parse().map_err(|e| { // Convert the parse error into an io::Error so the signatures line up. io::Error::new(io::ErrorKind::InvalidData, e) })?; Ok(value * 2) } }
Notice map_err — it adapts the error type when ? cannot convert it automatically (see the next section).
6.5 Converting errors with From
? does one more thing automatically: if the function's error type E implements From for the inner error, ? converts it for you. This lets different subsystems that produce different error types flow into a single error type at the boundary.
#![allow(unused)] fn main() { use std::fs; use std::io; use std::num::ParseIntError; // One error type that unifies several lower-level errors. #[derive(Debug)] enum AppError { Io(io::Error), Parse(ParseIntError), } // These conversions let `?` work without an explicit `map_err`. impl From<io::Error> for AppError { fn from(err: io::Error) -> Self { AppError::Io(err) } } impl From<ParseIntError> for AppError { fn from(err: ParseIntError) -> Self { AppError::Parse(err) } } fn load_number(path: &str) -> Result<i32, AppError> { let text = fs::read_to_string(path)?; // io::Error -> AppError automatically let n: i32 = text.trim().parse()?; // ParseIntError -> AppError automatically Ok(n) } }
Writing From impls by hand is mechanical. In practice, a derive macro does it for you — that is the topic of the next section.
6.6 Custom error types with thiserror
For libraries, define a dedicated error enum and derive the boilerplate (Debug, Display, From) with thiserror.
#![allow(unused)] fn main() { // Cargo.toml // [dependencies] // thiserror = "1" }
#![allow(unused)] fn main() { use std::io; use std::num::ParseIntError; use thiserror::Error; /// All the ways our config loader can fail. #[derive(Debug, Error)] enum ConfigError { #[error("could not read file: {0}")] Io(#[from] io::Error), #[error("invalid number in config: {0}")] Parse(#[from] ParseIntError), #[error("missing required key: {key}")] Missing { key: String }, } fn load_port(path: &str) -> Result<u16, ConfigError> { let text = std::fs::read_to_string(path)?; // io::Error auto-converts let port: u16 = text.trim().parse()?; // ParseIntError auto-converts if port == 0 { return Err(ConfigError::Missing { key: "port".into() }); } Ok(port) } }
The #[from] attribute generates the From impl, so ? just works. The #[error("...")] attribute provides the human-readable Display message. This is the recommended way to model errors in any code intended for reuse.
6.7 thiserror vs anyhow — library vs application
A common source of confusion: which error type should I use? The answer depends on whether your code is a library (called by others) or an application (the top-level program).
- Libraries should return a specific, structured error type so callers can match on it and react. Use
thiserror. - Applications mostly just want to bundle any error with context and report it at the top. Use
anyhow, which provides a singleanyhow::Errorthat can hold any error and a.context(...)method to attach a human-readable message.
#![allow(unused)] fn main() { // Cargo.toml // [dependencies] // anyhow = "1" }
use anyhow::{Context, Result}; use std::fs; fn load_port(path: &str) -> Result<u16> { let text = fs::read_to_string(path) .with_context(|| format!("failed to read config file {path:?}"))?; let port: u16 = text.trim().parse() .with_context(|| format!("port in {path:?} is not a valid number"))?; Ok(port) } fn main() -> Result<()> { let port = load_port("config.toml")?; println!("listening on {port}"); Ok(()) }
If load_port fails, anyhow prints a chain like:
Error: port in "config.toml" is not a valid number
Caused by:
invalid digit found in string
Guideline. Return thiserror-based errors from libraries; use anyhow::Result in binaries, tests, and glue code. The two compose perfectly: an anyhow::Error can wrap any error that implements std::error::Error, which thiserror types do.
6.8 Error handling in async code
In async functions, ? works exactly the same way — but the error travels out through a Future rather than a direct return. The only subtlety is selecting an error type that is Send when the future is sent across threads (e.g. with a multi-threaded Tokio runtime).
#![allow(unused)] fn main() { // Cargo.toml // [dependencies] // tokio = { version = "1", features = ["full"] } // anyhow = "1" }
use anyhow::{Context, Result}; use tokio::fs; use tokio::io::AsyncReadExt; async fn read_head(path: &str) -> Result<String> { let mut file = fs::File::open(path) .await .with_context(|| format!("open {path:?}"))?; let mut buf = [0u8; 64]; let n = file.read(&mut buf) .await .context("read first bytes")?; Ok(String::from_utf8_lossy(&buf[..n]).into_owned()) } #[tokio::main] async fn main() -> Result<()> { let head = read_head("README.md").await?; println!("{head}"); Ok(()) }
The pattern is identical to the synchronous case: each fallible .await is followed by ? or .context(...). Treat async error handling as ordinary Result handling that happens to be interrupted by .await points.
6.9 Best Practices
- Model failure in the type system. Prefer
Resultoverpanic!for anything a caller might want to handle. - Let
?propagate. Resist the urge tomatchevery error —?is clearer and shorter. - Attach context early. Use
.context()/.with_context()so the top-level error message explains what you were trying to do, not just the low-level cause. - Libraries: structured errors. Use
thiserrorand expose a publicErrorenum so callers can match on variants. - Applications:
anyhow. Useanyhow::Resultfor top-level orchestration and glue code. - Avoid
unwrap/expectin production paths. Keep them for tests, examples, and truly impossible states. - Don't discard errors. Never write
let _ = fallible();unless you genuinely intend to ignore the outcome — and even then, add a comment. - Errors are values. Log, wrap, retry, or convert them — but do so explicitly.
6.10 Summary
Rust treats errors as values. Unrecoverable bugs become panic!; expected failures become Result. The ? operator makes propagation concise, the From trait (often via thiserror) makes conversion automatic, and anyhow keeps application code tidy. The result is error handling that is explicit enough to reason about, yet ergonomic enough to actually use everywhere.
Exercises
- Write a function
parse_pair(s: &str) -> Result<(i32, i32), ParseIntError>that parses"3,4"into(3, 4). Extend it with a custom error that also reports a missing comma. - Build a
thiserror-basedWeatherErrorenum with variants for network and parse failures, then write an async function that fetches and parses a JSON-like string using?. - Rewrite a function that currently uses
unwrap()three times to use?andanyhow::Result, adding a.context()to each fallible step.
Chapter 7: Collections & Data Structures
So far, every value we have owned has lived on the stack or in a fixed-size array. Real programs need to grow data at runtime — queues of jobs, caches of records, sets of unique IDs. Rust's standard library provides a small, well-chosen set of collections for these jobs. This chapter covers the three you will use every day — Vec, HashMap, and HashSet — plus the iterator machinery that makes them expressive.
Learning Objectives
- Use
Vec<T>correctly, including capacity and slicing. - Choose between
HashMapandHashSetand use them efficiently. - Combine collections with iterators and closures for concise data pipelines.
- Recognize when to reach for
BTreeMap,VecDeque, orLinkedList. - Avoid the common performance pitfalls of each structure.
7.1 Vec<T> — the growable array
A Vec stores values contiguously on the heap. It tracks three things: a pointer to the data, a length (how many elements exist), and a capacity (how much memory is allocated). Appending is amortized O(1); indexing is O(1).
fn main() { // Three ways to create a Vec. let mut a: Vec<i32> = Vec::new(); // empty let b = vec![1, 2, 3]; // from a macro let mut c = Vec::with_capacity(100); // preallocated a.push(10); a.push(20); c.extend([1, 2, 3]); // Read by index (panics if out of bounds) or by get (returns Option). let first = b[0]; // 1 let maybe = b.get(10); // None — safe lookup println!("{first} {maybe:?}"); // Iterate by value (consumes the Vec) or by reference. for n in &b { println!("{n}"); } }
Capacity matters
When a Vec runs out of capacity, it allocates a larger buffer (usually double) and copies the elements over. If you know the final size, preallocate — it avoids repeated reallocation:
#![allow(unused)] fn main() { // Good: one allocation. let mut squares: Vec<i32> = Vec::with_capacity(1000); for i in 0..1000 { squares.push(i * i); } }
Vec::with_capacity is one of the highest-leverage optimizations in everyday Rust. Use it whenever the size is known or can be estimated.
7.2 HashMap<K, V> — keyed lookup
A HashMap stores key/value pairs with average O(1) lookup, insertion, and removal. Keys must implement Hash and Eq.
use std::collections::HashMap; fn main() { let mut scores: HashMap<String, i32> = HashMap::new(); scores.insert(String::from("alice"), 10); scores.insert(String::from("bob"), 7); // `entry` inserts a default only if the key is absent — no double lookup. scores.entry(String::from("alice")).or_insert(50); scores.entry(String::from("carol")).or_insert(3); // Read returns Option<&V>. if let Some(score) = scores.get("alice") { println!("alice: {score}"); } // Iterate over (&K, &V) pairs. for (name, score) in &scores { println!("{name}: {score}"); } }
The entry API (entry().or_insert()) is the idiomatic way to "insert if missing, otherwise read/modify" in a single pass. For counting words:
use std::collections::HashMap; fn word_count(text: &str) -> HashMap<&str, u32> { let mut counts = HashMap::new(); for word in text.split_whitespace() { let count = counts.entry(word).or_insert(0); *count += 1; } counts } fn main() { let counts = word_count("the quick brown fox the lazy dog the"); println!("{counts:?}"); }
7.3 HashSet<T> — unique values
A HashSet is a HashMap without values — a set of unique items, again with average O(1) operations.
use std::collections::HashSet; fn main() { let mut seen: HashSet<&str> = HashSet::new(); for word in ["a", "b", "a", "c", "b"] { // `insert` returns false if the value was already present. if !seen.insert(word) { println!("duplicate: {word}"); } } println!("unique: {seen:?}"); }
Sets support the usual set operations — union, intersection, difference, symmetric_difference — all returned as lazy iterators.
7.4 Iterators and closures
Collections become powerful once you combine them with iterators. An iterator is a lazy sequence: it produces values on demand and is zero-cost (it compiles down to the same machine code as a hand-written loop).
fn main() { let nums = vec![1, 2, 3, 4, 5, 6]; // A pipeline: filter -> map -> collect. let doubled_evens: Vec<i32> = nums .iter() .filter(|&&n| n % 2 == 0) // closure: keep evens .map(|&n| n * 2) // closure: double each .collect(); // materialize into a Vec println!("{doubled_evens:?}"); // [4, 8, 12] // Aggregations without explicit loops. let sum: i32 = nums.iter().sum(); let max = nums.iter().copied().max(); println!("sum={sum} max={max:?}"); }
Closures capture their environment by reference (|n|, move |n|). The & patterns like |&&n| come from iterating over &Vec<i32> (an iterator of &i32, then pattern-matched).
Owning vs borrowing iterators
.iter()yields&T— borrow..iter_mut()yields&mut T— mutable borrow..into_iter()yieldsT— consumes the collection.
#![allow(unused)] fn main() { let v = vec![1, 2, 3]; let borrowed: Vec<&i32> = v.iter().collect(); // v still usable let owned: Vec<i32> = v.into_iter().collect(); // v consumed }
7.5 Choosing a collection
| Need | Use | Notes |
|---|---|---|
| Growable, ordered, indexable | Vec<T> | Default choice. Cache-friendly. |
| Fast keyed lookup | HashMap<K,V> | No ordering. Average O(1). |
| Unique values | HashSet<T> | Set algebra available. |
| Sorted keyed lookup | BTreeMap<K,V> | O(log n), keys in order. |
| Double-ended queue | VecDeque<T> | Fast push/pop at both ends. |
| Queue, FIFO | VecDeque<T> | Prefer over LinkedList. |
| Stack (LIFO) | Vec<T> | Just use push / pop. |
| Doubly-linked list | LinkedList<T> | Rarely the right choice in Rust. |
Default to Vec. It is almost always the fastest structure for moderate sizes thanks to cache locality. Reach for a map only when you genuinely need keyed access.
7.6 Worked example: an in-memory index
Putting the pieces together — a tiny index from a word to the line numbers where it appears, demonstrating HashMap, Vec, entry, and iterators.
use std::collections::HashMap; fn build_index(lines: &[&str]) -> HashMap<&str, Vec<usize>> { let mut index: HashMap<&str, Vec<usize>> = HashMap::new(); for (i, line) in lines.iter().enumerate() { for word in line.split_whitespace() { index.entry(word).or_default().push(i); } } index } fn main() { let text = [ "the quick brown fox", "the lazy dog", "quick brown dog", ]; let index = build_index(&text); // Print sorted by word using BTreeMap for deterministic order. let sorted: std::collections::BTreeMap<_, _> = index.into_iter().collect(); for (word, lines) in sorted { println!("{word:>8}: {lines:?}"); } }
or_default() works because Vec implements Default (an empty vec), giving us a one-liner that inserts a new list or appends to an existing one.
7.7 Common pitfalls
- Repeated
pushwithout preallocation. UseVec::with_capacitywhen the size is predictable. HashMapwith a poor hasher for trusted inputs. The default hasher (SipHash) is DoS-resistant but slower. For trusted, non-adversarial data, considerahashorfnv.- Collecting when you only need to iterate. If you only loop over the result, skip
.collect()and stay lazy. - Holding
&mutto aVecwhile reading an index. Borrowing rules prevent this; restructure to compute the index first. - Using
LinkedListfor a queue.VecDequeis almost always faster and friendlier to the borrow checker.
7.8 Summary
Vec is the default collection — growable, contiguous, cache-friendly. HashMap and HashSet give average O(1) keyed lookup and uniqueness. Iterators and closures turn these structures into expressive, zero-cost data pipelines. Choose the simplest structure that fits, preallocate when you can, and let the borrow checker guide you toward correct access patterns.
Exercises
- Implement
dedup_preserve_order<T: Eq + Hash + Clone>(v: &[T]) -> Vec<T>using aHashSetfor seen-tracking. - Write a function that takes a
Vec<i32>and returns the sum of squares of its positive values, using only iterator combinators (no explicit loops). - Build a
HashMap<String, Vec<String>>grouping words by their first letter, using the entry API.
Chapter 8: Modules & Project Organization
Once a program grows beyond a single screen, organization matters as much as correctness. Rust gives you a module system that controls visibility, resolves paths, and splits code across crates and workspaces. This chapter shows how to structure a project so that it scales from a few hundred lines to a large codebase without becoming a tangle.
Learning Objectives
- Declare modules and submodules with
mod. - Control what is public with
pubandpub(crate). - Bring items into scope with
use, including aliases and re-exports. - Split a crate across files following Rust's path conventions.
- Organize a multi-crate project with a Cargo workspace.
8.1 Modules: the basics
A module groups related items and gives them a namespace. Declare one with mod:
mod network { pub fn connect(host: &str) { println!("connecting to {host}"); configure(); } fn configure() { // private — only visible inside `network` println!("configuring socket"); } } fn main() { network::connect("example.com"); // network::configure(); // error: `configure` is private }
Items are private by default. pub makes them visible outside their module. This default is the opposite of many languages and is a deliberate safety feature: you must explicitly opt in to exposing an API.
8.2 Paths and use
To reach an item you qualify it with a path: crate::network::connect, or from another module network::connect. The use declaration shortens this:
mod network { pub mod tcp { pub fn listen(port: u16) { println!("listening on {port}"); } } } use network::tcp::listen; // bring `listen` into scope fn main() { listen(8080); // no need to qualify }
Two useful use forms:
#![allow(unused)] fn main() { // Group several paths from the same module. use std::io::{self, Read, Write}; // Re-export so callers see a shorter path. pub use network::tcp::listen as tcp_listen; }
When two imported items have the same name, you can alias one: use std::fmt::Result as FmtResult;.
8.3 Splitting code into files
Rust lets a module's body live in another file. The convention:
src/
├── main.rs
├── network.rs // contents of `mod network` declared in main.rs
└── network/
└── tcp.rs // contents of `mod tcp` declared in network.rs
In main.rs you declare the modules without a body, and Rust finds the corresponding file:
// src/main.rs mod network; fn main() { network::tcp::listen(8080); }
#![allow(unused)] fn main() { // src/network.rs pub mod tcp; // Rust looks for src/network/tcp.rs }
#![allow(unused)] fn main() { // src/network/tcp.rs pub fn listen(port: u16) { println!("listening on {port}"); } }
The rules: mod foo; with no body tells Rust to look for foo.rs or foo/mod.rs. Declare submodules in the file that corresponds to their parent.
8.4 Visibility in depth
Visibility controls who may name an item.
| Visibility | Accessible from |
|---|---|
(default) private | The current module and its descendants only. |
pub | Any module that can name it. |
pub(crate) | Anything in the current crate. |
pub(super) | The parent module. |
pub(in path) | A specific ancestor module. |
pub(crate) is the workhorse for library internals that several modules share but that you do not want to expose to users of the crate:
#![allow(unused)] fn main() { pub(crate) fn internal_cache_key(s: &str) -> String { format!("cache:{s}") } }
A subtle rule: making a struct pub does not make its fields public. You must mark each field pub individually:
#![allow(unused)] fn main() { pub struct User { pub name: String, // public created_at: u64, // private — callers cannot read or write it } }
8.5 Crates and packages
A crate is the unit of compilation. A package is a directory with a Cargo.toml that contains one or more crates. A binary crate has a main function; a library crate does not.
A common layout for a package that ships both a library and a binary:
src/
├── lib.rs // library crate root
├── main.rs // binary crate root — uses the library
└── ...
#![allow(unused)] fn main() { // src/lib.rs pub fn greet(name: &str) { println!("hello, {name}"); } }
// src/main.rs use my_crate::greet; // the binary depends on its own library fn main() { greet("world"); }
Keeping the real logic in the library and main.rs thin makes your code testable: tests can link the library directly.
8.6 Workspaces
When a project contains several crates that evolve together, a workspace shares a single target/ directory and Cargo.lock:
# Cargo.toml at the workspace root
[workspace]
members = ["core", "cli", "server"]
Each member is its own crate with its own Cargo.toml. They depend on each other by path:
# cli/Cargo.toml
[dependencies]
core = { path = "../core" }
Workspaces keep build times reasonable (one target/ dir) and let you version and test the crates together while keeping their boundaries clean.
8.7 The standard library prelude
Some items are always in scope without a use — Vec, String, Option, Result, println!. This is the prelude, a small set of the most common types re-exported by the standard library. You never need to import them.
8.8 Best Practices
- Start flat, extract modules as patterns emerge. Do not pre-build a deep folder tree for a tiny program.
- Re-export a clean public API at the crate root. Users should
use your_crate::Thing, not navigate your internal module tree. - Prefer
pub(crate)overpubfor internals. Keep your public surface small. - Put logic in the library, not
main.rs. It pays off the first time you write a test. - Group related constants and types in a module rather than letting them float at the crate root.
8.9 Summary
Rust's module system is about controlled visibility: everything is private by default, and you expose exactly what callers need. use brings paths into scope, mod (with files) splits code across the filesystem, and crates plus workspaces scale the structure across teams. Keep the public API narrow, the library fat, and main.rs thin.
Exercises
- Take a single-file program with three concerns (parsing, processing, output) and split it into three modules in separate files.
- Add a
pub(crate)helper used by two modules, and verify that an external user cannot name it. - Convert a single-crate package into a workspace with a
corelibrary and aclibinary that depends on it.
Chapter 9: Concurrency
Rust's promise of "fearless concurrency" rests on one fact: the same ownership and borrowing rules that prevent memory errors also prevent data races at compile time. If two threads share data, the compiler insists that the sharing is safe — either immutable, or guarded by a synchronization primitive. This chapter covers the two mainstream models, threads with shared state and message passing, and the traits (Send, Sync) that make them safe.
Learning Objectives
- Spawn threads and join them.
- Share data safely with
Arc,Mutex, andRwLock. - Communicate between threads with channels (
mpsc). - Understand
SendandSyncand why they matter. - Avoid the common deadlocks and pitfalls of shared-state concurrency.
9.1 Spawning threads
std::thread::spawn starts an OS thread and returns a JoinHandle. Call .join() to wait for it to finish.
use std::thread; fn main() { let handle = thread::spawn(|| { for i in 0..5 { println!("child says {i}"); } }); for i in 0..3 { println!("main says {i}"); } handle.join().unwrap(); // wait for the child thread }
Closures and move
A spawned thread cannot borrow local variables unless they live long enough — and main might return before the thread finishes. The fix is move, which transfers ownership of captured variables into the closure:
use std::thread; fn main() { let data = vec![1, 2, 3]; let handle = thread::spawn(move || { // `data` is now owned by this thread. println!("got {} items", data.len()); }); handle.join().unwrap(); // println!("{:?}", data); // error: `data` was moved into the thread }
9.2 Send and Sync
These two marker traits are the foundation of thread safety. You do not implement them yourself; the compiler derives them automatically when all fields are Send/Sync.
Send: a typeTisSendif it is safe to move aTto another thread.Sync: a typeTisSyncif it is safe for multiple threads to hold&Tsimultaneously (i.e.,&TisSend).
Most types are both. The exceptions are types with interior mutability without synchronization — Rc<T> is the classic example: it is not Send or Sync, because its reference counting is not atomic. Use Arc<T> (atomic reference count) instead when sharing across threads.
9.3 Shared state: Arc + Mutex
To share mutable data between threads, combine:
Arc<T>— an atomically reference-counted pointer, so several threads can own the same allocation.Mutex<T>— a lock that guarantees exclusive access to the inner value.
use std::sync::{Arc, Mutex}; use std::thread; fn main() { // Shared, mutable counter protected by a mutex, shared via Arc. let counter = Arc::new(Mutex::new(0)); let handles: Vec<_> = (0..10) .map(|_| { let counter = Arc::clone(&counter); thread::spawn(move || { let mut num = counter.lock().unwrap(); *num += 1; }) }) .collect(); for h in handles { h.join().unwrap(); } println!("final = {}", *counter.lock().unwrap()); // 10 }
.lock().unwrap() deserves a word: a Mutex can be poisoned if a thread panics while holding the lock. .lock() then returns Err. Calling .unwrap() propagates the panic, which is usually the right thing — a poisoned lock means the data may be in an inconsistent state.
RwLock — many readers, one writer
When reads vastly outnumber writes, RwLock allows multiple simultaneous readers:
#![allow(unused)] fn main() { use std::sync::RwLock; let cache = RwLock::new(0); // Multiple readers can hold read locks at once. { let r1 = cache.read().unwrap(); let r2 = cache.read().unwrap(); println!("reads: {} {}", *r1, *r2); } // A writer gets exclusive access. { let mut w = cache.write().unwrap(); *w += 1; } }
9.4 Message passing: channels
The other concurrency model is to share data by communicating, not by sharing memory. Rust's std::sync::mpsc (multi-producer, single-consumer) channel does this.
use std::sync::mpsc; use std::thread; use std::time::Duration; fn main() { let (tx, rx) = mpsc::channel(); let sender = thread::spawn(move || { let messages = ["hi", "from", "the", "thread"]; for m in messages { tx.send(m).unwrap(); thread::sleep(Duration::from_millis(10)); } }); // `rx` is an iterator that yields received values until the sender drops. for received in rx { println!("got: {received}"); } sender.join().unwrap(); }
sendreturnsResultbecause the receiver might have been dropped.- Multiple producers: clone
txwithtx.clone()and move each into a different thread.
Channels decouple the threads: the sender does not need to know who reads, and the locking is hidden inside the channel implementation.
9.5 A tiny worker pool
Putting the pieces together — a pool of worker threads consuming jobs from a shared queue:
use std::sync::{Arc, Mutex}; use std::sync::mpsc; use std::thread; fn main() { let (tx, rx) = mpsc::channel::<Box<dyn FnOnce() + Send>>(); let rx = Arc::new(Mutex::new(rx)); // Spawn four workers that share the receiver. let mut workers = Vec::new(); for _ in 0..4 { let rx = Arc::clone(&rx); workers.push(thread::spawn(move || loop { let job = { let lock = rx.lock().unwrap(); lock.recv() }; match job { Ok(task) => task(), Err(_) => break, // all senders dropped — shut down } })); } // Send a few jobs. for i in 0..8 { tx.send(Box::new(move || { println!("job {i} on {:?}", thread::current().id()); })) .unwrap(); } drop(tx); // close the channel so workers can exit for w in workers { w.join().unwrap(); } }
The Mutex around the receiver is necessary because mpsc::Receiver is not Sync — only one thread may call recv at a time.
9.6 Pitfalls
- Deadlock. Acquiring two locks in different orders in different threads deadlocks. Acquire locks in a consistent global order, or use a single lock that guards both resources.
- Holding a lock across
.awaitin async code. Astd::sync::Mutexguard is not designed to span.awaitpoints. In async code, usetokio::sync::Mutex, or scope the guard so it drops before the.await. - Using
Rcacross threads. The compiler rejects it (Rcis notSend). Switch toArc. - Forgetting to
join. Detached threads may outlive the data they reference — but since Rust forcesmoveor'staticborrows, this is caught at compile time. - Too much locking. If every operation takes a global mutex, you have serialized the work. Prefer finer-grained locks, sharded data, or channels.
9.7 Summary
Rust makes data races impossible by construction: shared mutable state requires a Mutex, reference counting across threads requires Arc, and the Send/Sync traits are checked at compile time. Use channels when threads communicate; use Arc<Mutex<T>> (or RwLock) when they share mutable data. The borrow checker turns concurrency bugs that are Heisenbergian in other languages into compile errors here.
Exercises
- Spawn ten threads, each incrementing a shared
Arc<Mutex<i32>>a thousand times, and print the final value. - Rewrite the previous exercise using a channel instead of shared state: each thread sends its increment count to a receiver that sums them.
- Build a pipeline where one thread generates numbers, a second squares them, and a third prints them, connected by two channels.
Chapter 10: Network Programming
Rust is a systems language, which means talking to the network is a first-class concern. The standard library gives you synchronous TCP and UDP; the ecosystem (Tokio, Hyper) gives you high-performance async networking. This chapter moves from raw sockets up to an HTTP server, so you understand each layer rather than just calling a framework.
Learning Objectives
- Open TCP and UDP connections with
std::net. - Build a simple synchronous TCP echo server and client.
- Use Tokio for async, concurrent network handling.
- Serve a minimal HTTP request with
hyperor a tiny hand-rolled parser. - Serialize and deserialize structured data with
serde.
10.1 TCP with the standard library
std::net::TcpStream is a bidirectional byte stream. The simplest client connects, writes, and reads back:
use std::io::{prelude::*, BufReader}; use std::net::TcpStream; fn main() -> std::io::Result<()> { let mut stream = TcpStream::connect("example.com:80")?; // Send an HTTP/1.0 request by hand. write!(stream, "GET / HTTP/1.0\r\nHost: example.com\r\n\r\n")?; // Read the first line of the response. let mut reader = BufReader::new(stream); let mut status = String::new(); reader.read_line(&mut status)?; println!("{status}"); Ok(()) }
A TCP server accepts connections in a loop:
use std::io::{prelude::*, BufReader}; use std::net::{TcpListener, TcpStream}; fn handle(mut stream: TcpStream) -> std::io::Result<()> { let mut reader = BufReader::new(&stream); let mut line = String::new(); reader.read_line(&mut line)?; println!("received: {line}"); stream.write_all(b"ack\n")?; Ok(()) } fn main() -> std::io::Result<()> { let listener = TcpListener::bind("127.0.0.1:7878")?; for stream in listener.incoming() { let stream = stream?; handle(stream)?; } Ok(()) }
TcpListener::bind returns io::Result because binding can fail (port in use). incoming() is an iterator yielding one io::Result<TcpStream> per connection.
Handling one client per thread
The synchronous server above processes clients serially. To serve them concurrently, move each connection onto its own thread:
use std::net::TcpListener; use std::thread; fn main() -> std::io::Result<()> { let listener = TcpListener::bind("127.0.0.1:7878")?; for stream in listener.incoming() { let stream = stream?; thread::spawn(move || { // handle(stream) — see above let _ = std::io::copy(&mut &stream[..], &mut &stream[..]); }); } Ok(()) }
This scales to thousands of idle connections but spends an OS thread per client — fine for many workloads, wasteful for very high concurrency, which is where async shines.
10.2 UDP
UDP is connectionless: you send datagrams without establishing a stream.
use std::net::UdpSocket; fn main() -> std::io::Result<()> { let socket = UdpSocket::bind("127.0.0.1:34254")?; let mut buf = [0; 1024]; // Echo received datagrams back to their sender. loop { let (amt, src) = socket.recv_from(&mut buf)?; socket.send_to(&buf[..amt], src)?; } }
Use UDP when a lost packet is acceptable (telemetry, games, DNS) or when you implement a reliability layer yourself.
10.3 Async networking with Tokio
Tokio provides non-blocking TCP/UDP with the same API shape, prefixed with Async. The advantage: a single thread can wait on tens of thousands of sockets at once via the OS's I/O multiplexer (epoll/kqueue/IOCP).
# Cargo.toml
[dependencies]
tokio = { version = "1", features = ["full"] }
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; #[tokio::main] async fn main() -> std::io::Result<()> { let listener = TcpListener::bind("127.0.0.1:7878").await?; loop { let (mut socket, _) = listener.accept().await?; // Spawn a task per connection — cheap, not an OS thread. tokio::spawn(async move { let mut buf = [0; 1024]; loop { let n = match socket.read(&mut buf).await { Ok(0) => return, // peer closed Ok(n) => n, Err(_) => return, }; if socket.write_all(&buf[..n]).await.is_err() { return; } } }); } }
This is an echo server that handles many clients concurrently on a small pool of threads. Each tokio::spawn creates a lightweight task, not an OS thread.
10.4 A minimal HTTP server
HTTP/1.1 is text on top of TCP. A tiny server can parse just the request line and respond:
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{TcpListener, TcpStream}; async fn handle(mut stream: TcpStream) -> std::io::Result<()> { let mut reader = BufReader::new(&mut stream); let mut request_line = String::new(); reader.read_line(&mut request_line).await?; let (method, path) = parse_request_line(&request_line); let body = match (method.as_str(), path.as_str()) { ("GET", "/") => "hello, world".to_string(), ("GET", "/time") => format!("{}", chrono::Utc::now()), _ => "not found".to_string(), }; let status = if path == "/" || path == "/time" { "200 OK" } else { "404 Not Found" }; let response = format!( "HTTP/1.1 {status}\r\nContent-Type: text/plain\r\nContent-Length: {}\r\n\r\n{body}", body.len() ); stream.write_all(response.as_bytes()).await?; Ok(()) } fn parse_request_line(line: &str) -> (String, String) { let mut parts = line.split_whitespace(); let method = parts.next().unwrap_or("").to_string(); let path = parts.next().unwrap_or("").to_string(); (method, path) } #[tokio::main] async fn main() -> std::io::Result<()> { let listener = TcpListener::bind("127.0.0.1:8080").await?; loop { let (stream, _) = listener.accept().await?; tokio::spawn(async move { if let Err(e) = handle(stream).await { eprintln!("error: {e}"); } }); } }
For anything beyond a toy, reach for a framework — axum, actix-web, or hyper directly — which handles chunked encoding, keep-alive, routing, and TLS correctly.
10.5 Serialization with serde
Network data is bytes; your program wants structs. serde is the standard serialization framework, and serde_json is its JSON frontend.
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Debug)] struct User { name: String, age: u8, } fn main() { let user = User { name: "alice".into(), age: 30 }; // Serialize to JSON. let json = serde_json::to_string(&user).unwrap(); println!("{json}"); // {"name":"alice","age":30} // Deserialize back. let parsed: User = serde_json::from_str(&json).unwrap(); println!("{parsed:?}"); }
serde works with many formats — bincode (compact binary), toml, yaml, protobuf via prost — all behind the same Serialize/Deserialize derive.
10.6 Best Practices
- Use
BufReader/BufWriter. Reading byte-by-byte off a socket is catastrophically slow; buffering is almost always right. - Bound your reads. Never allocate a buffer based on an untrusted length field without a cap — a classic denial-of-service vector.
- Set timeouts. A socket that never receives data can hang forever. Use
stream.set_read_timeout(Some(...))or, in async,tokio::time::timeout. - Prefer async for high fan-out. If you expect thousands of concurrent connections, a thread-per-connection model wastes memory.
- TLS in production. Plaintext TCP is fine for learning; for anything exposed to the internet, terminate TLS (e.g.
rustls).
10.7 Summary
std::net gives you synchronous TCP and UDP; Tokio gives you the same primitives non-blocking, so one thread can manage thousands of sockets. HTTP is text on TCP, and a small server is within reach, though production code should lean on axum or hyper. Everywhere, serde moves between bytes and typed structs. Network programming in Rust is low-level when you need it to be and ergonomic when you want it to be.
Exercises
- Extend the sync TCP server so it echoes each line back to the client until the client disconnects.
- Convert it to async with Tokio, and add a 5-second read timeout per connection.
- Build a JSON-over-TCP server that receives a
serderequest struct and replies with a response struct.
Chapter 11: Database Operations
Most applications eventually store data in a database. Rust's database story centers on sqlx — an async, compile-time-checked SQL toolkit — and serde for moving rows to and from structs. This chapter covers connecting, querying, pooling, transactions, and migrations, with SQLite for examples that run on your machine without a server.
Learning Objectives
- Connect to a database and run queries with
sqlx. - Map rows to structs and use compile-time SQL checking.
- Manage a connection pool and understand its tuning knobs.
- Use transactions for atomic, multi-statement updates.
- Run and author database migrations.
11.1 Setting up
sqlx supports PostgreSQL, MySQL/MariaDB, SQLite, and MSSQL. For this chapter we use SQLite so every example runs locally.
# Cargo.toml
[dependencies]
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "macros"] }
serde = { version = "1", features = ["derive"] }
A connection string for SQLite is just a file path:
sqlite://todos.db?mode=rwc
mode=rwc creates the file if it does not exist.
11.2 Connecting and querying
sqlx::SqlitePool is a pool of connections; acquire or an implicit query will check one out, run, and return it.
use sqlx::sqlite::SqlitePool; async fn create_table(pool: &SqlitePool) -> sqlx::Result<()> { sqlx::query( "CREATE TABLE IF NOT EXISTS todos ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0 )", ) .execute(pool) .await?; Ok(()) } #[tokio::main] async fn main() -> sqlx::Result<()> { let pool = SqlitePool::connect("sqlite:todos.db?mode=rwc").await?; create_table(&pool).await?; Ok(()) }
Insert with bound parameters
Always bind user-supplied values as parameters — never interpolate them into the SQL string, which invites injection:
#![allow(unused)] fn main() { async fn add_todo(pool: &SqlitePool, title: &str) -> sqlx::Result<i64> { let result = sqlx::query("INSERT INTO todos (title) VALUES (?)") .bind(title) .execute(pool) .await?; Ok(result.last_insert_rowid()) } }
11.3 Mapping rows to structs
Use query_as with a struct that derives FromRow:
#![allow(unused)] fn main() { use sqlx::FromRow; #[derive(Debug, FromRow)] struct Todo { id: i64, title: String, done: bool, } async fn list_todos(pool: &SqlitePool) -> sqlx::Result<Vec<Todo>> { sqlx::query_as::<_, Todo>("SELECT id, title, done FROM todos ORDER BY id") .fetch_all(pool) .await } }
fetch_all loads every row into a Vec; fetch_one returns a single row; fetch returns a Stream for large result sets.
11.4 Compile-time SQL checking
sqlx::query! (and query_as!) macros parse and type-check your SQL at compile time against a live database (or a saved schema). If you typo a column, the build fails.
#![allow(unused)] fn main() { async fn titles(pool: &SqlitePool) -> sqlx::Result<Vec<String>> { // Verified at build time against the `todos` table. let rows = sqlx::query!("SELECT title FROM todos WHERE done = 0") .fetch_all(pool) .await?; Ok(rows.into_iter().map(|r| r.title).collect()) } }
To use the macros you either set DATABASE_URL so the macro connects at build time, or run cargo sqlx prepare to generate a .sqlx/ cache checked into version control — essential for CI without a database.
11.5 Connection pools
A pool keeps a set of connections warm, avoiding the cost of reconnecting per query. Tune three knobs:
max_connections— ceiling on live connections.min_connections— floor kept open and ready.acquire_timeout— how long to wait when all are busy.
#![allow(unused)] fn main() { use sqlx::sqlite::{SqlitePool, SqlitePoolOptions}; use std::time::Duration; let pool = SqlitePoolOptions::new() .max_connections(10) .min_connections(2) .acquire_timeout(Duration::from_secs(5)) .connect("sqlite:todos.db?mode=rwc") .await?; }
A common mistake is making max_connections huge — databases have their own limits, and oversubscribing causes contention rather than speed.
11.6 Transactions
A transaction makes a group of statements atomic: either all commit, or none do. This is the only correct way to move money between accounts, insert a parent and its children, or update a counter that must stay consistent.
#![allow(unused)] fn main() { async fn transfer(pool: &SqlitePool, from: i64, to: i64, amount: i64) -> sqlx::Result<()> { let mut tx = pool.begin().await?; sqlx::query("UPDATE accounts SET balance = balance - ? WHERE id = ?") .bind(amount).bind(from) .execute(&mut *tx).await?; sqlx::query("UPDATE accounts SET balance = balance + ? WHERE id = ?") .bind(amount).bind(to) .execute(&mut *tx).await?; tx.commit().await?; // apply both — or roll back on error Ok(()) } }
If any statement fails, the ? returns early and the transaction is automatically rolled back when tx is dropped. Explicit tx.rollback().await is available when you want to abort on purpose.
11.7 Migrations
Schema evolves. sqlx::migrate! bundles migration files and applies the pending ones at startup.
migrations/
├── 20240101000000_init.sql
└── 20240201000000_add_index.sql
-- migrations/20240101000000_init.sql
CREATE TABLE todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0
);
async fn main() -> sqlx::Result<()> { let pool = SqlitePool::connect("sqlite:todos.db?mode=rwc").await?; sqlx::migrate!("./migrations").run(&pool).await?; Ok(()) }
sqlx records applied migrations in a _sqlx_migrations table, so re-running the binary only applies what is missing.
11.8 Best Practices
- Bind, never interpolate. Parameters are both safer and often faster (the driver can cache the prepared statement).
- Pool once, share everywhere. Create one pool at startup and clone the
Poolhandle (it is just anArcinside) into handlers. - Keep transactions short. Long-running transactions hold locks and hurt throughput.
- Migrate at deploy time, not per request. Run migrations as a startup step or a separate command.
- Use
query_as!for type safety. Compile-time checking catches a large class of bugs for free.
11.9 Summary
sqlx gives Rust async, type-checked database access. Bind parameters to prevent injection, map rows with FromRow, manage a single connection pool for the whole program, guard multi-step writes with transactions, and evolve the schema with migrations. The result is database code that is as statically checked as the rest of your program.
Exercises
- Build a CLI that adds, lists, and completes todos in a SQLite database.
- Add a
usersand apoststable, and write a transaction that creates a user and their first post atomically. - Convert the
query_ascalls toquery_as!macros and set upcargo sqlx preparefor CI.
Chapter 12: Web Development
A web service in Rust is typically built on the tokio + axum stack: Tokio provides the async runtime, and Axum gives routing, extractors, and response handlers with a clean, type-driven API. This chapter builds a small JSON API end-to-end — routing, state, validation, and error responses — so you see how the pieces compose.
Learning Objectives
- Build an HTTP API with
axumon top oftokio. - Read request bodies and path/query parameters with extractors.
- Share state across handlers safely.
- Return typed JSON responses and a consistent error format.
- Compose middleware (logging, recovery).
12.1 A first server
# Cargo.toml
[dependencies]
tokio = { version = "1", features = ["full"] }
axum = "0.7"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
use axum::{routing::get, Router}; async fn hello() -> &'static str { "hello, world" } #[tokio::main] async fn main() { let app = Router::new().route("/", get(hello)); let listener = tokio::net::TcpListener::bind("127.0.0.1:8080").await.unwrap(); axum::serve(listener, app).await.unwrap(); }
A handler is just an async fn that returns something implementing IntoResponse. &'static str becomes a 200 OK with a text body. Routing maps HTTP methods plus paths to handlers.
12.2 Path and query parameters
Extractors are Axum's signature feature: the compiler reads a handler's argument types and parses the request for you.
#![allow(unused)] fn main() { use axum::extract::Path; // /users/42 -> id = 42 async fn show_user(Path(id): Path<u32>) -> String { format!("user {id}") } }
#![allow(unused)] fn main() { use axum::extract::Query; use serde::Deserialize; #[derive(Deserialize)] struct Pagination { page: Option<u32>, size: Option<u32> } // /items?page=2&size=10 async fn list_items(Query(p): Query<Pagination>) -> String { format!("page {:?}, size {:?}", p.page.unwrap_or(1), p.size.unwrap_or(20)) } }
The order of extractors matters: Path and Query are fine anywhere, but the body-consuming extractor (Json, String) must come last.
12.3 JSON bodies and responses
Json<T> both deserializes the request body and serializes the response:
#![allow(unused)] fn main() { use axum::{Json, response::IntoResponse}; use serde::{Deserialize, Serialize}; #[derive(Deserialize)] struct CreateTodo { title: String } #[derive(Serialize)] struct Todo { id: u64, title: String, done: bool } // Receives {"title":"..."}, returns the created todo as JSON. async fn create_todo(Json(input): Json<CreateTodo>) -> impl IntoResponse { let todo = Todo { id: 1, title: input.title, done: false }; (axum::http::StatusCode::CREATED, Json(todo)) } }
If the body fails to parse, Axum returns 400 Bad Request automatically — you do not write that code.
12.4 Shared state
Most handlers need a database pool or a cache. Put it in a struct wrapped in Arc, pass it to Router::with_state, and extract it with State:
use axum::extract::State; use std::sync::Arc; #[derive(Clone)] struct AppState { counter: Arc<std::sync::atomic::AtomicU64>, } async fn increment(State(state): State<Arc<AppState>>) -> String { let n = state.counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); format!("you are visitor {}", n + 1) } #[tokio::main] async fn main() { let state = Arc::new(AppState { counter: Arc::new(std::sync::atomic::AtomicU64::new(0)), }); let app = Router::new() .route("/visit", get(increment)) .with_state(state); let listener = tokio::net::TcpListener::bind("127.0.0.1:8080").await.unwrap(); axum::serve(listener, app).await.unwrap(); }
The state type must be Clone (usually via an inner Arc), because Axum hands a cheap clone to each request.
12.5 A consistent error format
Returning Result from a handler lets you centralize error handling. Define your error type and an IntoResponse impl that maps it to a uniform JSON shape:
#![allow(unused)] fn main() { use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use serde_json::json; enum ApiError { NotFound, BadRequest(String), } impl IntoResponse for ApiError { fn into_response(self) -> Response { let (status, msg) = match self { ApiError::NotFound => (StatusCode::NOT_FOUND, "not found"), ApiError::BadRequest(reason) => (StatusCode::BAD_REQUEST, reason.leak()), }; let body = Json(json!({ "error": msg })); (status, body).into_response() } } async fn get_todo(Path(id): Path<u32>) -> Result<String, ApiError> { if id == 0 { return Err(ApiError::BadRequest("id must be positive".into())); } if id > 100 { return Err(ApiError::NotFound); } Ok(format!("todo {id}")) } }
Now every error response has the same {"error": "..."} shape, and handlers stay focused on the happy path.
12.6 Middleware
Middleware wraps the router to add cross-cutting behavior. tower_http ships common layers: logging, CORS, compression, and a catch-all catch_panic to turn panics into 500s.
[dependencies]
tower-http = { version = "0.5", features = ["trace", "cors"] }
tower = "0.4"
tracing-subscriber = "0.3"
#![allow(unused)] fn main() { use tower_http::trace::TraceLayer; use tower_http::cors::CorsLayer; let app = Router::new() .route("/", get(hello)) .layer(TraceLayer::new_for_http()) .layer(CorsLayer::permissive()); }
Layers apply in reverse order: the last .layer added runs first on the request.
12.7 Static files and templates
To serve a frontend alongside your API, use tower_http::services::ServeDir as a fallback, and render HTML server-side with askama (compile-time templates, like Jinja2) or maud (HTML as Rust macros). The choice is a matter of taste; both avoid runtime template parsing.
12.8 Best Practices
- Handlers should be thin. Push logic into the library; the handler parses input, calls a service, and shapes the response.
- One error type per API. Map it with
IntoResponseso all errors look uniform. - Validate at the boundary. Reject malformed input before it reaches your domain code —
serdeplus avalidatorcrate covers most cases. - Share state through
Arc, notstatic. It composes with tests. - Layer observability early.
TraceLayerplustracinggives you structured logs you will be grateful for in production.
12.9 Summary
axum turns HTTP into typed Rust: extractors parse the request, Json serializes the body, State shares resources, and an error type with IntoResponse keeps responses uniform. Layered middleware adds logging, CORS, and recovery. The result is a web service that feels like the rest of your statically-checked codebase.
Exercises
- Build a
/todosresource withGET(list),POST(create), andGET /:id(show), backed by an in-memoryVecbehind aMutex. - Add an
ApiErrortype and return404for unknown ids and400for empty titles. - Add a
TraceLayerand atracingsubscriber that logs each request with its method, path, and status.
Chapter 13: Performance Optimization
The first rule of optimization is: measure, then optimize. Rust is already fast by default, so most "optimization" is about not throwing that speed away — avoiding needless allocations, laying out data for the cache, and choosing the right concurrency model. This chapter is a toolkit for finding bottlenecks and a catalog of the techniques that matter.
Learning Objectives
- Establish a performance baseline with benchmarks before changing code.
- Profile CPU and memory to find real bottlenecks, not guesses.
- Reduce allocations and copies, the most common Rust performance wins.
- Lay out data for cache locality.
- Choose between threads and async based on workload.
13.1 Measure first
Never optimize from intuition. Establish a baseline with criterion, which runs statistical benchmarks and reports noise:
# Cargo.toml
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
[[bench]]
name = "string_join"
harness = false
#![allow(unused)] fn main() { // benches/string_join.rs use criterion::{criterion_group, criterion_main, Criterion}; fn bench_join(c: &mut Criterion) { let words: Vec<String> = (0..1000).map(|i| i.to_string()).collect(); c.bench_function("join_with_plus", |b| { b.iter(|| { let mut s = String::new(); for w in &words { s += w; } s }) }); c.bench_function("join_with_iter", |b| { b.iter(|| words.join("")) }); } criterion_group!(benches, bench_join); criterion_main!(benches); }
Run with cargo bench. Criterion tells you whether a change is a real win or within noise.
13.2 Profiling
For a whole program, profile with perf (Linux), Instruments (macOS), or cargo flamegraph:
cargo install flamegraph
cargo flamegraph --bin myapp
A flamegraph shows where CPU time is spent, aggregated across the call tree. Look for surprising hot spots — a clone you did not expect, a format! in a tight loop, a hash function dominating.
13.3 Allocations are the usual suspect
Heap allocation is cheap, but thousands per second add up. The biggest wins usually come from removing allocations:
#![allow(unused)] fn main() { // Bad: allocates a new String on every call. fn bad(items: &[i32]) -> String { let mut s = String::new(); for x in items { s += &x.to_string(); } s } // Better: one allocation, sized up front. fn good(items: &[i32]) -> String { // Each i32 is at most 11 chars; preallocate to avoid regrowth. let mut s = String::with_capacity(items.len() * 11); for x in items { s.push_str(&x.to_string()); } s } }
Common allocation patterns to question:
clone()inside a loop — can you borrow instead?to_string()to compare against a&str— compare with==directly.format!for logging that is usually disabled — uselog/tracingmacros, which skip formatting when the level is off.- Collecting into a
Veconly to iterate it once — stay lazy with iterators.
13.4 String handling
String is heap-allocated and growable; &str is a borrowed slice. Prefer &str in function arguments. When you must build a string, use String::with_capacity or write! into a String:
#![allow(unused)] fn main() { use std::fmt::Write; let mut out = String::with_capacity(64); write!(out, "x={}, y={}", 10, 20).unwrap(); }
For ASCII identifiers, CompactString or simply an interned &'static str can avoid per-call allocation.
13.5 Cache locality and data layout
Modern CPUs are fast at arithmetic and slow at memory access. Data that is contiguous and accessed sequentially is dramatically faster than pointer-chasing. This is why Vec beats LinkedList almost always, and why a struct of arrays can outperform an array of structs when you iterate over one field:
#![allow(unused)] fn main() { // Array of structs — natural but touches three cache lines per item. struct Particle { x: f64, y: f64, v: f64 } let aos: Vec<Particle> = /* ... */; // Struct of arrays — iterating `xs` streams one contiguous buffer. struct Particles { xs: Vec<f64>, ys: Vec<f64>, vs: Vec<f64> } }
If a benchmark shows you spend time loading data you never use, restructuring into a struct of arrays (or splitting a large struct into hot and cold parts) is often a 2–10× win.
13.6 Hashing
The default HashMap uses SipHash, which is DoS-resistant but slower than alternatives. For trusted, non-adversarial keys, ahash or rustc-hash (FNV-style) is several times faster:
[dependencies]
ahash = "0.8"
#![allow(unused)] fn main() { use ahash::AHashMap; let mut m: AHashMap<&str, i32> = AHashMap::new(); }
13.7 Inlining and generics
Generic functions in Rust are monomorphized — the compiler generates a separate copy per concrete type, which enables inlining and is usually faster than dynamic dispatch. Prefer generics over dyn Trait in hot paths:
#![allow(unused)] fn main() { // Generic — monomorphized, inlinable, fast. fn sum<T: Copy + std::ops::Add<Output = T>>(xs: &[T], zero: T) -> T { xs.iter().fold(zero, |a, &b| a + b) } // Trait object — one copy, virtual dispatch, harder to inline. fn sum_dyn(xs: &[Box<dyn Numeric>]) -> f64 { /* ... */ } }
Use #[inline] sparingly — the compiler is good at it; reserve hints for tiny leaf functions in libraries that callers will want inlined across crate boundaries.
13.8 Async versus threads
-
CPU-bound work: use threads or
rayon's data parallelism.rayonturnsiter()intopar_iter():#![allow(unused)] fn main() { use rayon::prelude::*; let total: u64 = (0..1_000_000).into_par_iter().map(|i| i * i).sum(); } -
I/O-bound work: use async. Spawning a task per connection is far cheaper than a thread.
-
Mixing them: keep blocking CPU work off the async runtime with
tokio::task::spawn_blocking, so it does not stall the reactor.
13.9 Best Practices
- Benchmark before and after. A change without a measurement is a guess.
- Profile the whole program, not a micro-benchmark, when you care about end-to-end speed.
- Cut allocations first. They are the easiest big win in idiomatic Rust.
- Respect the cache. Contiguous, sequential, predictable access wins.
- Don't fight the optimizer. Write clear, monomorphized code;
cargo build --releasedoes the rest.
13.10 Summary
Performance work in Rust starts with measurement: criterion for micro-benchmarks, flamegraph for the whole program. The common wins are fewer allocations, better cache layout, and the right concurrency model — threads and rayon for CPU work, async for I/O. Most Rust code is already fast; these techniques keep it that way as it grows.
Exercises
- Benchmark
Vec::pushwith and withoutwith_capacity, and report the difference. - Rewrite a struct-of-arrays example and benchmark a sum over one field against the array-of-structs version.
- Replace a
HashMapwith anAHashMapin a hot loop and measure the change.
Chapter 14: Security Programming
Rust eliminates entire classes of vulnerabilities by construction — buffer overflows, use-after-free, null pointer dereferences, and data races are compile-time errors, not runtime exploits. But memory safety is not the whole story. A secure application must also validate input, manage secrets, authenticate users, and resist the attacks that target any web service. This chapter covers the practical security practices you add on top of Rust's guarantees.
Learning Objectives
- Understand what Rust prevents automatically and what it does not.
- Validate and sanitize untrusted input.
- Hash and salt passwords correctly.
- Manage secrets without leaking them into logs or source control.
- Apply TLS and common web-security headers.
14.1 What Rust prevents, and what it does not
Rust's ownership model makes these impossible in safe code:
- Buffer overflows — bounds-checked array access panics instead of overflowing.
- Use-after-free and double-free — the move/borrow system prevents aliased mutable access to freed memory.
- Null pointer dereferences — there is no null; absence is
Option<T>. - Data races —
Send/Syncmake concurrent mutation without synchronization a compile error.
What Rust does not prevent:
- Logic bugs — correct memory, wrong answer.
- Integer overflow in release builds (it wraps; use
checked_*/saturating_*when it matters). - Panics from untrusted input —
unwrapon attacker-controlled data crashes the process. - Injection — building SQL, HTML, or shell commands from raw input.
- Leaking secrets — a
Stringholding a password is just memory the compiler will happily print.
Security is therefore about the boundary between your program and untrusted data.
14.2 Validate input at the boundary
The first line of defense is to reject malformed input before it reaches your domain logic. serde deserialization already catches type errors; for semantic rules, use the validator crate:
[dependencies]
validator = { version = "0.16", features = ["derive"] }
#![allow(unused)] fn main() { use validator::Validate; #[derive(serde::Deserialize, Validate)] struct Signup { #[validate(length(min = 3, max = 32))] username: String, #[validate(email)] email: String, #[validate(length(min = 8))] password: String, } fn handle_signup(input: Signup) -> Result<(), String> { input.validate().map_err(|e| e.to_string())?; Ok(()) } }
Treat all external data — HTTP bodies, query strings, environment variables, file contents — as untrusted until validated.
14.3 SQL and command injection
The rule is absolute: never interpolate untrusted data into a command string. Use parameter binding for SQL (Chapter 11) and typed argument arrays for subprocesses:
#![allow(unused)] fn main() { use std::process::Command; // Good — arguments are passed, not parsed by a shell. let output = Command::new("ls") .arg("-l") .arg(user_path) // safe even if it contains spaces or ";" .output()?; }
Avoid Command::new("sh").arg("-c").arg(format!("ls {user_path}")) — that hands the user's input to a shell and reopens injection.
14.4 Passwords: hash, never store
Never store passwords in plaintext or with a reversible cipher. Use a slow, salted hash designed for passwords. argon2 is the current standard:
[dependencies]
argon2 = "0.5"
#![allow(unused)] fn main() { use argon2::{ password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Argon2, }; fn hash_password(plain: &str) -> Result<String, argon2::password_hash::Error> { let salt = SaltString::generate(&mut OsRng); let hash = Argon2::default().hash_password(plain.as_bytes(), &salt)?; Ok(hash.to_string()) } fn verify_password(plain: &str, stored: &str) -> Result<(), argon2::password_hash::Error> { let parsed = PasswordHash::new(stored)?; Argon2::default().verify_password(plain.as_bytes(), &parsed) } }
The stored string embeds the salt and parameters, so verification is a one-liner. Never roll your own hashing.
14.5 Secrets management
A secret (API key, database password, token) must satisfy three rules: it comes from the environment, not the source; it is loaded once and held in memory; and it never reaches logs.
#![allow(unused)] fn main() { use std::env; struct Config { db_url: String, api_key: String, } impl Config { fn from_env() -> Result<Self, String> { Ok(Config { db_url: env::var("DATABASE_URL").map_err(|_| "DATABASE_URL missing")?, api_key: env::var("API_KEY").map_err(|_| "API_KEY missing")?, }) } } }
Practical safeguards:
- Load at startup from environment variables or a secret manager — never hard-code, never commit to git.
- Mark secret fields so logging crates skip them (
tracingsupports#[redact]-style patterns viasecrecy). - Use the
secrecycrate to wrap secrets in aSecret<String>that does not implementDisplay, so an accidentalprintln!is a compile error.
[dependencies]
secrecy = "0.8"
#![allow(unused)] fn main() { use secrecy::Secret; let api_key: Secret<String> = Secret::new(env::var("API_KEY")?); // println!("{}", api_key); // would not compile }
14.6 TLS
Plaintext on the public internet is inexcusable. Use rustls (a pure-Rust TLS stack) to terminate TLS, either inside your server or at a reverse proxy. For client HTTPS, reqwest uses rustls by default:
#![allow(unused)] fn main() { let resp = reqwest::get("https://example.com").await?.text().await?; }
Pin to specific TLS versions (require TLS 1.2+) and a curated cipher list; the defaults are conservative and usually correct.
14.7 Web security headers
A few response headers stop whole classes of browser attacks:
| Header | Purpose |
|---|---|
Content-Security-Policy | Restrict where scripts/styles may load from — defeats most XSS. |
Strict-Transport-Security | Force HTTPS for future visits (HSTS). |
X-Content-Type-Options: nosniff | Stop MIME-type sniffing. |
X-Frame-Options: DENY | Prevent clickjacking via iframes. |
In axum, add these with tower_http::set_header::SetResponseHeaderLayer, or use a dedicated middleware. CSP is the most powerful — a strict policy stops reflected XSS even if you have a rendering bug.
14.8 Authentication and sessions
For cookie-based authentication:
- Issue a random, unguessable session token (use
getrandomoruuidv4). - Store the session server-side, mapped to a user id, with an expiry.
- Set the cookie
HttpOnly(no JS access),Secure(HTTPS only), andSameSite=Lax(CSRF defense). - Rotate the token on privilege change (login, privilege escalation).
For stateless tokens (JWT), sign them with a strong algorithm (EdDSA or HS256 with a long key), set a short expiry, and revoke via a server-side denylist for sensitive operations.
14.9 Best Practices
- Treat all external input as hostile until validated.
- Bind, never interpolate — for SQL, shell, and URL construction.
- Hash passwords with Argon2; never store or log them.
- Load secrets from the environment, wrap them so they cannot be printed.
- TLS everywhere on the public internet.
- Set security headers, especially CSP.
- Keep dependencies current —
cargo auditflags known vulnerabilities.
14.10 Summary
Rust removes the memory-safety attack surface, which is a large fraction of real-world vulnerabilities. What remains is the application layer: validating input, preventing injection, hashing passwords, protecting secrets, and terminating TLS. Combine Rust's compile-time guarantees with these boundary disciplines and you have a notably harder target than most stacks.
Exercises
- Add
validatorto a signup handler and reject usernames shorter than 3 characters or emails that are not valid. - Hash and verify a password with
argon2, and store the hash string in a SQLite row. - Wrap an API key in
secrecy::Secretand confirm the compiler rejects an accidentalprintln!. - Add
Strict-Transport-Securityand a basicContent-Security-Policyheader to an Axum router.
Chapter 15: Testing & Debugging
Rust's testing story is built into the language: the compiler knows about #[test], the standard library ships the assertions, and cargo test runs everything. This chapter covers unit, integration, and doc tests, then moves to the tools that find bugs the compiler cannot — property testing, structured logging, and the debugger.
Learning Objectives
- Write unit, integration, and doc tests.
- Organize test modules and use the common assertion macros.
- Test async code and external dependencies.
- Generate randomized inputs with property-based testing.
- Debug with
tracingandlldb.
15.1 Unit tests
A unit test lives next to the code it tests, in a #[cfg(test)] module so it is compiled only for cargo test:
#![allow(unused)] fn main() { // src/math.rs pub fn add(a: i32, b: i32) -> i32 { a + b } #[cfg(test)] mod tests { use super::*; #[test] fn add_works() { assert_eq!(add(2, 2), 4); assert_eq!(add(-1, 1), 0); } #[test] #[should_panic(expected = "overflow")] fn overflow_panics() { // Demonstrates checking for an expected panic. panic!("overflow"); } } }
Core assertions:
| Macro | Checks |
|---|---|
assert!(cond) | Condition is true. |
assert_eq!(a, b) | Two values are equal. |
assert_ne!(a, b) | Two values differ. |
should_panic | The test panics (optionally with a message). |
Keep tests small, focused, and independent — each tests one behavior.
15.2 Integration tests
Integration tests live in tests/ and exercise the crate's public API as an external user would. Each file is a separate binary:
#![allow(unused)] fn main() { // tests/api.rs use my_crate::add; #[test] fn add_from_outside() { assert_eq!(add(3, 4), 7); } }
Use integration tests for end-to-end paths and unit tests for internal branches.
15.3 Doc tests
Code blocks in /// doc comments are compiled and run by cargo test. They double as examples and as a correctness check that the documented API actually works:
#![allow(unused)] fn main() { /// Adds two integers. /// /// ``` /// use my_crate::add; /// assert_eq!(add(2, 2), 4); /// ``` pub fn add(a: i32, b: i32) -> i32 { a + b } }
If an example should not run, mark it ```no_run or ```ignore. Doc tests keep your documentation honest.
15.4 Testing async code
tokio provides a #[tokio::test] attribute that wraps the test in a runtime:
#![allow(unused)] fn main() { use tokio; #[tokio::test] async fn fetches_a_value() { let result = some_async_fn().await; assert_eq!(result, 42); } }
For code with timers, use tokio::time::pause and advance to make tests deterministic without real delays.
15.5 Mocking and dependency injection
Rust has no built-in mocking framework, and that is by design — the idiomatic approach is dependency injection via traits. Define a small trait for the external dependency, write a fake implementation in tests, and pass it in:
#![allow(unused)] fn main() { pub trait Clock { fn now(&self) -> u64; } pub struct SystemClock; impl Clock for SystemClock { fn now(&self) -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() } } pub fn greet(name: &str, clock: &dyn Clock) -> String { let hour = (clock.now() / 3600) % 24; if hour < 12 { format!("good morning, {name}") } else { format!("hello, {name}") } } #[cfg(test)] mod tests { use super::*; struct FixedClock(u64); impl Clock for FixedClock { fn now(&self) -> u64 { self.0 } } #[test] fn morning() { assert_eq!(greet("alice", &FixedClock(7 * 3600)), "good morning, alice"); } } }
For heavier mocking, mockall generates mock implementations of traits automatically.
15.6 Property-based testing
Instead of writing one example at a time, state a property that should always hold and let the framework search for a counterexample. proptest is the standard crate:
[dev-dependencies]
proptest = "1"
#![allow(unused)] fn main() { proptest::proptest! { #[test] fn add_is_commutative(a in -1000i32..1000, b in -1000i32..1000) { proptest::prop_assert_eq!(add(a, b), add(b, a)); } #[test] fn sort_is_idempotent(mut v in proptest::collection::vec(-100i32..100, 0..100)) { v.sort(); let mut w = v.clone(); w.sort(); proptest::prop_assert_eq!(v, w); } } }
Property tests find edge cases you would not think to write — empty inputs, maximum values, off-by-ones — by shrinking a failing random case to a minimal reproducer.
15.7 Debugging with tracing
println! works, but tracing gives you structured, leveled, context-aware logs that work across async tasks:
[dependencies]
tracing = "0.1"
tracing-subscriber = "0.3"
use tracing::{info, instrument, span, Level}; #[instrument] fn process(user: &str) { let _span = span!(Level::INFO, "step", user = %user).entered(); info!("started processing"); // ... } fn main() { tracing_subscriber::fmt::init(); process("alice"); }
Spans attach context (the function, its arguments) to every log line beneath them, which is invaluable when many requests are interleaved.
15.8 The debugger
When logs are not enough, use lldb (or gdb, or the IDE's debugger) with a debug build:
cargo build
lldb -- target/debug/myapp
Set breakpoints with b function_name, step with n / s, and inspect with p variable. For panics, run with RUST_BACKTRACE=1 to get a stack trace without a debugger:
RUST_BACKTRACE=1 cargo run
15.9 Best Practices
- Test behavior, not implementation. A test that reaches into private internals breaks on every refactor.
- One assertion per test where possible. Narrow tests pinpoint the failure.
- Keep the fast tests fast. Move slow integration tests behind a feature flag so
cargo teststays snappy. - Write the failing test first. It confirms the bug exists before you fix it.
- Property-test pure functions. They are where
proptestshines.
15.10 Summary
cargo test runs unit tests in #[cfg(test)] modules, integration tests in tests/, and doc tests in /// comments — one command, three kinds of coverage. Inject dependencies via traits to test in isolation, hunt edge cases with proptest, and reach for tracing and lldb when behavior goes wrong. Testing in Rust is boring in the best way: it is just code, compiled and run by the same toolchain.
Exercises
- Add unit and doc tests to a
sortwrapper and confirm both run undercargo test. - Use
proptestto verify that reversing aVectwice yields the original. - Add a
tracingspan to a function and inspect the output withtracing_subscriber::fmt.
Chapter 16: Deployment & Operations
Writing the code is half the job; shipping and running it is the other half. This chapter covers the practical lifecycle of a Rust service in production: release builds, container images, configuration, health checks, graceful shutdown, observability, and zero-downtime updates. Rust's static, single-binary output makes most of this remarkably easy.
Learning Objectives
- Produce optimized release binaries and understand what
--releasedoes. - Package an application as a minimal Docker image.
- Configure with environment variables and files, for twelve-factor deployment.
- Implement health checks and graceful shutdown.
- Observe a running service with logs, metrics, and traces.
16.1 Release builds
Debug builds are for development; production runs cargo build --release. The release profile turns on optimizations (opt-level = 3), disables debug assertions, and uses the system allocator tuned for throughput. For a service, consider tightening it:
# Cargo.toml
[profile.release]
lto = "thin" # link-time optimization across crates
codegen-units = 1 # better optimization, slower compile
strip = true # remove debug symbols for a smaller binary
panic = "abort" # smaller binary, no unwinding overhead
panic = "abort" is a trade-off: smaller, faster binaries, but no stack unwinding — a panicking thread tears down the whole process, which is often what you want in a service supervised by a restart policy.
16.2 A minimal Docker image
Rust produces a statically linked (or near-static) binary, so the runtime image can be tiny. A multi-stage build compiles in a full image and copies the binary into scratch or debian:bookworm-slim:
# Build stage
FROM rust:1.78 AS builder
WORKDIR /app
COPY . .
RUN cargo build --release
# Runtime stage
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/myapp /usr/local/bin/myapp
RUN useradd -r -s /bin/false appuser
USER appuser
EXPOSE 8080
ENTRYPOINT ["myapp"]
The result is an image of tens of megabytes, not gigabytes, with no toolchain inside — a smaller attack surface and faster pulls.
16.3 Configuration
Keep configuration in the environment, following the twelve-factor methodology. A typical setup reads environment variables, with a local file for development:
#![allow(unused)] fn main() { use std::env; struct Config { port: u16, database_url: String, log_level: String, } impl Config { fn from_env() -> Result<Self, String> { Ok(Config { port: env::var("PORT").unwrap_or_else(|_| "8080".into()).parse().map_err(|_| "PORT not a number")?, database_url: env::var("DATABASE_URL").map_err(|_| "DATABASE_URL missing")?, log_level: env::var("LOG_LEVEL").unwrap_or_else(|_| "info".into()), }) } } }
Never bake secrets into the image. Inject them at runtime from a secret manager or orchestration platform (Kubernetes secrets, Docker secrets, cloud secret managers).
16.4 Health checks and readiness
Orchestrators need to know whether your service is alive and ready. Expose two endpoints:
/health(liveness) — "the process is up." Returns200unconditionally; used to decide whether to restart the container./ready(readiness) — "I can handle traffic." Returns200only when the database is connected and warm-up is complete; used to decide whether to route traffic.
#![allow(unused)] fn main() { use axum::{routing::get, Router, http::StatusCode}; let app = Router::new() .route("/health", get(|| async { StatusCode::OK })) .route("/ready", get(|| async { StatusCode::OK })); }
A failing dependency should make /ready return 503, not crash the process.
16.5 Graceful shutdown
When a deployment rolls, the orchestrator sends SIGTERM and waits a short grace period before SIGKILL. Your service should stop accepting new connections, finish in-flight requests, and exit. axum::serve supports this directly:
use axum::{routing::get, Router}; async fn handler() -> &'static str { "ok" } #[tokio::main] async fn main() { let app = Router::new().route("/", get(handler)); let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap(); axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal()) .await .unwrap(); } async fn shutdown_signal() { use tokio::signal; let ctrl_c = async { signal::ctrl_c().await.expect("install ctrl-c handler"); }; #[cfg(unix)] let terminate = async { signal::unix::signal(signal::unix::SignalKind::terminate()) .expect("install terminate handler") .recv() .await; }; #[cfg(not(unix))] let terminate = std::future::pending::<()>(); tokio::select! { _ = ctrl_c => {}, _ = terminate => {}, } println!("shutdown signal received"); }
Combined with the readiness check, this gives zero-downtime rolling updates: traffic drains before the process exits.
16.6 Observability
A production service needs three signals: logs, metrics, and traces.
- Logs — structured, via
tracing. Emit JSON so a log aggregator can index it. - Metrics — counters and histograms via
prometheusormetricscrate, exposed at/metricsfor scraping. - Traces — distributed spans via
tracing-opentelemetry, so you can follow a request across services.
#![allow(unused)] fn main() { tracing_subscriber::fmt() .json() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); }
The single most useful metric is the RED set: Rate, Errors, Duration of requests. Track those per route and you have most of what operations needs.
16.7 Best Practices
- Build once, run anywhere. A release binary that reads its config from the environment works in Docker, systemd, or Kubernetes unchanged.
- Fail fast on config. If a required variable is missing, exit with a clear message at startup — do not limp along.
- Keep the runtime image small.
scratchor a slim base, no toolchain, no source. - Implement both health endpoints. Liveness is not readiness.
- Handle
SIGTERM. Graceful shutdown is what makes rolling updates safe. - Observe from day one. Retrofitting logs and metrics is painful.
16.8 Summary
A Rust service ships as a single, optimized binary in a small container, configured by environment variables, supervised via health checks, and drained gracefully on SIGTERM. The release profile and a multi-stage Docker image are the mechanical core; liveness/readiness endpoints, graceful shutdown, and structured observability are what make it operable in production. Rust's output is unusually easy to deploy — spend that ease on good operational hygiene.
Exercises
- Configure the release profile with
lto,strip, andpanic = "abort", and compare binary size and startup time. - Write a multi-stage Dockerfile that builds your Axum service and runs it as a non-root user.
- Add
/healthand/readyendpoints and aSIGTERMgraceful-shutdown handler. - Emit JSON logs with
tracing_subscriberand filter them by level throughRUST_LOG.
Chapter 17: Embedded Rust
Rust runs on microcontrollers. The same ownership model that secures a web service also prevents the memory bugs that make embedded development painful — and no_std lets you drop the standard library entirely, leaving only the language core. This chapter is a short tour: what no_std means, the embedded-hal abstraction layer, and a blinking LED on a typical microcontroller.
Learning Objectives
- Understand
#![no_std]and the core vs. alloc vs. std layers. - Use
embedded-haltraits to write portable peripheral code. - Cross-compile for a microcontroller target.
- Blink an LED with a hardware-abstraction crate (PAC/HAL).
- Know where to go deeper into the embedded ecosystem.
17.1 The three layers: core, alloc, std
Rust code targets one of three layers, controlled by attributes:
| Layer | Provides | Attribute | Typical use |
|---|---|---|---|
std | Heap, threads, files, networking | (default) | Desktop, server. |
alloc | Box, Vec, String, Arc | #![no_std] + extern crate alloc | OS kernels, larger embedded. |
core | Slices, iterators, Option/Result | #![no_std] | Microcontrollers, bootloaders. |
A #![no_std] binary drops std and links only core (and optionally alloc). Anything you write against core works everywhere — including std programs — which is why library authors prefer no_std-compatible code where feasible.
#![allow(unused)] #![no_std] fn main() { // Only `core` is available: no Vec, no String, no println!, no threads. pub fn sum(slice: &[i32]) -> i32 { slice.iter().copied().sum() } }
17.2 embedded-hal: portable traits
The genius of the embedded ecosystem is embedded-hal, a set of traits that describe peripherals generically: a GPIO pin, a serial port, an I²C bus, a timer. Code written against these traits runs unchanged on any chip whose HAL implements them.
#![allow(unused)] fn main() { use embedded_hal::digital::OutputPin; // This function blinks any pin that implements OutputPin — any chip, any HAL. pub fn blink<P: OutputPin>(pin: &mut P, count: u8) { for _ in 0..count { let _ = pin.set_high(); // delay omitted for brevity let _ = pin.set_low(); } } }
Because the trait is generic, the same blink works on an STM32, an ESP32, or an nRF52 — only the concrete pin type changes at the call site.
17.3 The PAC, HAL, and BSP stack
Embedded Rust is layered:
- PAC (Peripheral Access Crate) — generated from the chip's SVD file; raw register access at addresses.
- HAL (Hardware Abstraction Layer) — implements
embedded-haltraits on top of the PAC, with a safe API. - BSP (Board Support Package) — pins and peripherals wired for a specific board (e.g. "the user LED is on PB5").
You usually write code against the HAL/BSP, dropping to the PAC only for unusual registers.
17.4 Cross-compiling
Rust cross-compiles by installing a target and pointing cargo at it:
# Add a target (example: Cortex-M4F, common on STM32 / nRF52).
rustup target add thumbv7em-none-eabihf
# Build without standard library, without an entry point defined by std.
cargo build --release --target thumbv7em-none-eabihf
The target triple thumbv7em-none-eabihf encodes the architecture, ABI, and hard-float. A #![no_std] binary also needs a custom entry point and a linker script; the cortex-m-rt crate and cortex-m-quickstart template provide these.
17.5 A blinky in outline
The shape of a blinky program (details vary by HAL):
#![no_std] #![no_main] use cortex_m_rt::entry; use embedded_hal::digital::OutputPin; use panic_halt as _; // define a panic handler: halt #[entry] fn main() -> ! { let (mut led, mut delay) = board::take_peripherals(); loop { led.set_high(); delay.delay_ms(500); led.set_low(); delay.delay_ms(500); } }
Three things stand out:
#![no_main]— there is no standardmain;#[entry]fromcortex-m-rtdefines the reset handler.panic_halt as _— a#![no_std]binary must supply a panic handler; this one halts the CPU.main -> !— embeddedmainnever returns; it loops forever.
17.6 Async on microcontrollers
embedded-hal now has async variants, and executors like embassy run futures on a microcontroller without an OS. This lets you write non-blocking drivers — reading a sensor while an LED blinks — with the same async/await you use on a server, on a chip with tens of kilobytes of RAM.
17.7 Resources
- The Embedded Rust Book —
docs.rust-embedded.org/book— the canonical tutorial. embedded-haldocs — the trait reference.probe-rs— flashing and debugging via a debug probe, replacing vendor toolchains.embassy— async embedded framework, growing fast.
17.8 Summary
Embedded Rust trades std for core, writes portable drivers against embedded-hal, and cross-compiles to bare-metal targets with the same cargo you already use. The result is microcontroller firmware with the same memory-safety guarantees as server code — a meaningful change for a domain long plagued by buffer overflows and dangling pointers.
Exercises
- Write a
#![no_std]functionfn count_ones(bytes: &[u8]) -> u32that counts set bits, and unit-test it withcargo teston your host. - Install the
thumbv7em-none-eabihftarget and confirm a#![no_std]crate builds for it. - Read the first chapter of the Embedded Rust Book and identify the PAC, HAL, and BSP for a board you own.
Chapter 18: Rust Resources & Official Book Guide
You have reached the end of this book, but Rust is a large language with a fast-moving ecosystem. This chapter is a curated map: the canonical texts to read next, the tools to install, the references to keep open, and a suggested path from "I can write Rust" to "I am fluent in Rust."
Learning Objectives
- Know the official documentation and when to consult each piece.
- Build a daily-driver toolset with
rustup,cargo,clippy, andrustfmt. - Navigate the crate ecosystem and evaluate quality.
- Follow a deliberate path to fluency.
18.1 The official documentation
The Rust project maintains a set of books, all free and cross-linked. Each has a job:
| Resource | URL | Read it for |
|---|---|---|
| The Rust Programming Language ("the Book") | doc.rust-ownership.org/book | A guided, project-based introduction. The canonical starting point. |
| Rust by Example | doc.rust-lang.org/rust-by-example | Runnable snippets organized by topic — a quick reference. |
| The Rust Reference | doc.rust-lang.org/reference | Precise, definitive language semantics (not a tutorial). |
| The Rustonomicon | doc.rust-lang.org/nomicon | The dark arts: unsafe, FFI, low-level memory. |
| The Async Book | rust-lang.github.io/async-book | How async/await works under the hood. |
| The Cargo Book | doc.rust-lang.org/cargo | Everything about the build system and packaging. |
| The Edition Guide | doc.rust-lang.org/edition-guide | What changed between the 2015, 2018, and 2021 editions. |
| The API Guidelines | rust-lang.github.io/api-guidelines | How to design a Rust API that feels idiomatic. |
| std API docs | doc.rust-lang.org/std | The standard library reference. |
A healthy habit: keep doc.rust-lang.org/std open while you code, and read the source of any type you use heavily — the standard library is exemplary Rust.
18.2 The toolset
Every Rust developer should have these wired into their editor and CI:
rustup— manage toolchains and targets.rustup updatekeeps you current;rustup component addadds tools.cargo— build, test, document, and publish.cargo checkis the fast feedback loop;cargo build --releaseis for shipping.rustfmt— the official formatter. Runcargo fmtso formatting is never a code-review discussion.clippy— the linter.cargo clippycatches a long list of common mistakes and non-idiomatic patterns. Treat its warnings seriously; many are genuine bugs.cargo doc --open— generates and serves the documentation for your crate and its dependencies. Reading your own docs is a fine way to evaluate your API.
rustup component add rustfmt clippy
cargo fmt
cargo clippy --all-targets -- -D warnings
cargo test
cargo doc --open
18.3 The crate ecosystem
A few crates are so widely used they are almost part of the language. Knowing them saves you from reinventing:
| Domain | Crate | Purpose |
|---|---|---|
| Serialization | serde, serde_json | (De)serialization, the universal format layer. |
| Errors | thiserror, anyhow | Library and application error types. |
| Async runtime | tokio | The dominant async runtime. |
| HTTP server | axum, actix-web | Web frameworks. |
| HTTP client | reqwest | High-level blocking and async client. |
| Database | sqlx | Async, compile-time-checked SQL. |
| Logging | tracing, tracing-subscriber | Structured logs and spans. |
| Random | rand | The randomness ecosystem. |
| Regex | regex | Perl-like regular expressions. |
| CLI parsing | clap | Argument parsing with derive macros. |
| Date/time | chrono, time | Date and time arithmetic. |
| Parallelism | rayon | Data-parallel iterators. |
Evaluating a crate: check the download count and recent version dates on crates.io, read the README, scan open issues, and prefer crates that are actively maintained and have documentation. A crate with no release in two years is a liability.
18.4 A path to fluency
- Read the Book end to end. It is short for what it covers and builds a project (a
grepclone) as it goes. - Do
rustlings. A set of small exercises that fill in the gaps the Book leaves to practice. - Build something real. A CLI tool, a small web service, a game — a project of your own surfaces the questions no tutorial anticipates.
- Read good code. The standard library,
serde,tokio, andaxumare all well-written and educational. - Write
unsafelast. Most Rust programmers rarely need it; when you do, read the Rustonomicon first. - Engage the community. The
rust-usersforum, the Discord, and local meetups are friendly and deep.
18.5 Staying current
Rust releases every six weeks — a stable release train, not a long wait between major versions. Most releases are incremental. Watch for the occasional edition (a chance to introduce small language conveniences without breaking the ecosystem) and the annual Rust survey for a pulse on where the community is heading.
Keep rustup update in your routine, skim the release notes, and let clippy and rustfmt absorb the small style changes for you.
18.6 Closing
Rust's promise is that you can write fast, low-level code without the fear that usually accompanies it. The compiler is strict, but that strictness is what lets you refactor a large codebase with confidence, ship a service that does not crash on a null, or ship firmware that does not overflow a buffer. The investment to learn it is real, and so is the payoff.
Keep the standard library docs open, write a little code every day, and let the borrow checker teach you. Welcome to Rust.