Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 stable channel is fine for everyday work. Try nightly for 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 use 2021.
  • [dependencies]: declares dependencies; cargo fetches them from crates.io and pins them in Cargo.lock.

Common commands:

CommandPurpose
cargo new <name>new binary project
cargo new --lib <name>new library project
cargo buildcompile (debug build)
cargo build --releaseoptimized build, for release/benchmarks
cargo runcompile and run
cargo checktype-check only (fastest)
cargo testrun all tests
cargo fmtformat code
cargo clippyrun lints
cargo doc --opengenerate and open docs

Debug vs release: the default cargo build is 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

  1. Create a project with cargo new, write a function returning the first N Fibonacci numbers, and verify with cargo run and cargo test.
  2. Add a dependency (e.g. rand) and inspect the generated docs with cargo doc --open.
  3. Write code that triggers a clippy warning (e.g. a needless return), run cargo 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, understand mut vs immutable, and shadowing.
  • Master scalar types (integers, floats, booleans, chars) and compound types (tuples, arrays).
  • Understand strings: the difference between String and &str.
  • Use if/loop/while/for and 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}");
}

mut vs shadowing: mut changes 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 with mut.

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

TypeMeaningExample
i8i128, isizesigned integer-5, 42
u8u128, usizeunsigned integer0, 255
f32, f64float3.14, 2.0
boolbooleantrue, false
charUnicode 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: 42 defaults to i32. Annotate when the context needs another type: let n: u8 = 42;. Integer overflow panics in debug builds and wraps in release — use checked_*, wrapping_*, or saturating_* 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 use Vec (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 + 1 is 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

  1. Write fn fizzbuzz(n: u32) that prints 1 to n by the classic FizzBuzz rules.
  2. Return both quotient and remainder from one function: fn divmod(a: i32, b: i32) -> (i32, i32).
  3. Sum the integers 1 to 100 with a for and a range, and note why an indexed while is 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 &T and 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] / &str to borrow a span of contiguous data.

3.1 The Three Rules of Ownership

Rust's memory management rests on three rules:

  1. Each value has a single owner — a variable.
  2. When the owner goes out of scope, the value is dropped (its destructor runs, memory is freed).
  3. 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 String to 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:

  1. At any moment, you may have either several shared references &T, or exactly one mutable reference &mut T — not both.
  2. 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:

  1. Each reference parameter gets its own lifetime.
  2. If there is exactly one input lifetime, it is assigned to all output references.
  3. 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

  1. Explain why let s2 = s1; (s1 is String) invalidates s1, while let b = a; (a is i32) leaves a usable.
  2. Write fn longest_word(s: &str) -> &str returning the first longest word (split on spaces). Annotate any lifetimes needed and observe the elision rules.
  3. Write a struct Config<'a> holding a &str and construct an instance; verify it cannot outlive the String it 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 Option and Result as enums.
  • Use match and if let for pattern matching.
  • Attach behavior to types with impl blocks.

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: ..u1 moves fields out of u1. Since u1.name is moved, u1 as a whole is no longer usable (unless all copied fields are Copy).


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 } binds x/y to 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

  1. Define a Point struct and a Shape enum (Circle, Rectangle, Triangle); use match to compute each shape's area.
  2. Add a birthday(&mut self) method to User that increments age, and an associated function User::new(name, age).
  3. Write a function returning the first positive number from a list as Option<i32>, and handle the result with if 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> and largest::<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());
    }
}
FormDispatchOverheadHolds many types?
Generic T: Traitstatic (monomorphized)noneno (one per type)
&dyn Trait / Box<dyn Trait>dynamic (vtable)one indirect callyes

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

  1. Write a generic fn first<T>(v: &[T]) -> Option<&T> returning the first element of a slice.
  2. Define a Drawable trait (fn draw(&self)), implement it for two different structs, and hold them in a Vec<Box<dyn Drawable>> to iterate.
  3. Add a take_n method to the Counter above (returning impl 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 From trait.
  • Design custom error types with thiserror, and choose anyhow for applications.
  • Apply error-handling best practices in async code.

6.1 Two Kinds of Errors

Rust groups errors into two families. The distinction is the foundation of everything in this chapter.

KindTypeMeaningExample
Unrecoverablepanic!A bug or a broken invariant — the program cannot safely continue.Index out of bounds, dividing by zero, a Mutex that was poisoned.
RecoverableResult<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 proper Result handling 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:

CombinatorReturnsPurpose
map(f)Option<U>Transform the inner value if present.
and_then(f)Option<U>Chain operations that themselves return Option.
unwrap_or(v)TFall back to v when None.
unwrap_or_default()TFall back to T::default().
is_some() / is_none()boolInspect 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 Optionmap, 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 single anyhow::Error that 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

  1. Model failure in the type system. Prefer Result over panic! for anything a caller might want to handle.
  2. Let ? propagate. Resist the urge to match every error — ? is clearer and shorter.
  3. 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.
  4. Libraries: structured errors. Use thiserror and expose a public Error enum so callers can match on variants.
  5. Applications: anyhow. Use anyhow::Result for top-level orchestration and glue code.
  6. Avoid unwrap/expect in production paths. Keep them for tests, examples, and truly impossible states.
  7. Don't discard errors. Never write let _ = fallible(); unless you genuinely intend to ignore the outcome — and even then, add a comment.
  8. 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

  1. 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.
  2. Build a thiserror-based WeatherError enum with variants for network and parse failures, then write an async function that fetches and parses a JSON-like string using ?.
  3. Rewrite a function that currently uses unwrap() three times to use ? and anyhow::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 HashMap and HashSet and use them efficiently.
  • Combine collections with iterators and closures for concise data pipelines.
  • Recognize when to reach for BTreeMap, VecDeque, or LinkedList.
  • 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() yields T — 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

NeedUseNotes
Growable, ordered, indexableVec<T>Default choice. Cache-friendly.
Fast keyed lookupHashMap<K,V>No ordering. Average O(1).
Unique valuesHashSet<T>Set algebra available.
Sorted keyed lookupBTreeMap<K,V>O(log n), keys in order.
Double-ended queueVecDeque<T>Fast push/pop at both ends.
Queue, FIFOVecDeque<T>Prefer over LinkedList.
Stack (LIFO)Vec<T>Just use push / pop.
Doubly-linked listLinkedList<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

  1. Repeated push without preallocation. Use Vec::with_capacity when the size is predictable.
  2. HashMap with a poor hasher for trusted inputs. The default hasher (SipHash) is DoS-resistant but slower. For trusted, non-adversarial data, consider ahash or fnv.
  3. Collecting when you only need to iterate. If you only loop over the result, skip .collect() and stay lazy.
  4. Holding &mut to a Vec while reading an index. Borrowing rules prevent this; restructure to compute the index first.
  5. Using LinkedList for a queue. VecDeque is 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

  1. Implement dedup_preserve_order<T: Eq + Hash + Clone>(v: &[T]) -> Vec<T> using a HashSet for seen-tracking.
  2. 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).
  3. 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 pub and pub(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.

VisibilityAccessible from
(default) privateThe current module and its descendants only.
pubAny 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 useVec, 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

  1. Start flat, extract modules as patterns emerge. Do not pre-build a deep folder tree for a tiny program.
  2. Re-export a clean public API at the crate root. Users should use your_crate::Thing, not navigate your internal module tree.
  3. Prefer pub(crate) over pub for internals. Keep your public surface small.
  4. Put logic in the library, not main.rs. It pays off the first time you write a test.
  5. 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

  1. Take a single-file program with three concerns (parsing, processing, output) and split it into three modules in separate files.
  2. Add a pub(crate) helper used by two modules, and verify that an external user cannot name it.
  3. Convert a single-crate package into a workspace with a core library and a cli binary 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, and RwLock.
  • Communicate between threads with channels (mpsc).
  • Understand Send and Sync and 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 type T is Send if it is safe to move a T to another thread.
  • Sync: a type T is Sync if it is safe for multiple threads to hold &T simultaneously (i.e., &T is Send).

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();
}
  • send returns Result because the receiver might have been dropped.
  • Multiple producers: clone tx with tx.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

  1. 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.
  2. Holding a lock across .await in async code. A std::sync::Mutex guard is not designed to span .await points. In async code, use tokio::sync::Mutex, or scope the guard so it drops before the .await.
  3. Using Rc across threads. The compiler rejects it (Rc is not Send). Switch to Arc.
  4. Forgetting to join. Detached threads may outlive the data they reference — but since Rust forces move or 'static borrows, this is caught at compile time.
  5. 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

  1. Spawn ten threads, each incrementing a shared Arc<Mutex<i32>> a thousand times, and print the final value.
  2. Rewrite the previous exercise using a channel instead of shared state: each thread sends its increment count to a receiver that sums them.
  3. 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 hyper or 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

  1. Use BufReader / BufWriter. Reading byte-by-byte off a socket is catastrophically slow; buffering is almost always right.
  2. Bound your reads. Never allocate a buffer based on an untrusted length field without a cap — a classic denial-of-service vector.
  3. Set timeouts. A socket that never receives data can hang forever. Use stream.set_read_timeout(Some(...)) or, in async, tokio::time::timeout.
  4. Prefer async for high fan-out. If you expect thousands of concurrent connections, a thread-per-connection model wastes memory.
  5. 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

  1. Extend the sync TCP server so it echoes each line back to the client until the client disconnects.
  2. Convert it to async with Tokio, and add a 5-second read timeout per connection.
  3. Build a JSON-over-TCP server that receives a serde request 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

  1. Bind, never interpolate. Parameters are both safer and often faster (the driver can cache the prepared statement).
  2. Pool once, share everywhere. Create one pool at startup and clone the Pool handle (it is just an Arc inside) into handlers.
  3. Keep transactions short. Long-running transactions hold locks and hurt throughput.
  4. Migrate at deploy time, not per request. Run migrations as a startup step or a separate command.
  5. 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

  1. Build a CLI that adds, lists, and completes todos in a SQLite database.
  2. Add a users and a posts table, and write a transaction that creates a user and their first post atomically.
  3. Convert the query_as calls to query_as! macros and set up cargo sqlx prepare for 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 axum on top of tokio.
  • 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

  1. Handlers should be thin. Push logic into the library; the handler parses input, calls a service, and shapes the response.
  2. One error type per API. Map it with IntoResponse so all errors look uniform.
  3. Validate at the boundary. Reject malformed input before it reaches your domain code — serde plus a validator crate covers most cases.
  4. Share state through Arc, not static. It composes with tests.
  5. Layer observability early. TraceLayer plus tracing gives 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

  1. Build a /todos resource with GET (list), POST (create), and GET /:id (show), backed by an in-memory Vec behind a Mutex.
  2. Add an ApiError type and return 404 for unknown ids and 400 for empty titles.
  3. Add a TraceLayer and a tracing subscriber 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 — use log / tracing macros, which skip formatting when the level is off.
  • Collecting into a Vec only 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. rayon turns iter() into par_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

  1. Benchmark before and after. A change without a measurement is a guess.
  2. Profile the whole program, not a micro-benchmark, when you care about end-to-end speed.
  3. Cut allocations first. They are the easiest big win in idiomatic Rust.
  4. Respect the cache. Contiguous, sequential, predictable access wins.
  5. Don't fight the optimizer. Write clear, monomorphized code; cargo build --release does 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

  1. Benchmark Vec::push with and without with_capacity, and report the difference.
  2. Rewrite a struct-of-arrays example and benchmark a sum over one field against the array-of-structs version.
  3. Replace a HashMap with an AHashMap in 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 racesSend/Sync make 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 inputunwrap on attacker-controlled data crashes the process.
  • Injection — building SQL, HTML, or shell commands from raw input.
  • Leaking secrets — a String holding 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 (tracing supports #[redact]-style patterns via secrecy).
  • Use the secrecy crate to wrap secrets in a Secret<String> that does not implement Display, so an accidental println! 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:

HeaderPurpose
Content-Security-PolicyRestrict where scripts/styles may load from — defeats most XSS.
Strict-Transport-SecurityForce HTTPS for future visits (HSTS).
X-Content-Type-Options: nosniffStop MIME-type sniffing.
X-Frame-Options: DENYPrevent 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 getrandom or uuid v4).
  • Store the session server-side, mapped to a user id, with an expiry.
  • Set the cookie HttpOnly (no JS access), Secure (HTTPS only), and SameSite=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

  1. Treat all external input as hostile until validated.
  2. Bind, never interpolate — for SQL, shell, and URL construction.
  3. Hash passwords with Argon2; never store or log them.
  4. Load secrets from the environment, wrap them so they cannot be printed.
  5. TLS everywhere on the public internet.
  6. Set security headers, especially CSP.
  7. Keep dependencies currentcargo audit flags 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

  1. Add validator to a signup handler and reject usernames shorter than 3 characters or emails that are not valid.
  2. Hash and verify a password with argon2, and store the hash string in a SQLite row.
  3. Wrap an API key in secrecy::Secret and confirm the compiler rejects an accidental println!.
  4. Add Strict-Transport-Security and a basic Content-Security-Policy header 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 tracing and lldb.

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:

MacroChecks
assert!(cond)Condition is true.
assert_eq!(a, b)Two values are equal.
assert_ne!(a, b)Two values differ.
should_panicThe 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

  1. Test behavior, not implementation. A test that reaches into private internals breaks on every refactor.
  2. One assertion per test where possible. Narrow tests pinpoint the failure.
  3. Keep the fast tests fast. Move slow integration tests behind a feature flag so cargo test stays snappy.
  4. Write the failing test first. It confirms the bug exists before you fix it.
  5. Property-test pure functions. They are where proptest shines.

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

  1. Add unit and doc tests to a sort wrapper and confirm both run under cargo test.
  2. Use proptest to verify that reversing a Vec twice yields the original.
  3. Add a tracing span to a function and inspect the output with tracing_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 --release does.
  • 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." Returns 200 unconditionally; used to decide whether to restart the container.
  • /ready (readiness) — "I can handle traffic." Returns 200 only 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 prometheus or metrics crate, exposed at /metrics for 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

  1. Build once, run anywhere. A release binary that reads its config from the environment works in Docker, systemd, or Kubernetes unchanged.
  2. Fail fast on config. If a required variable is missing, exit with a clear message at startup — do not limp along.
  3. Keep the runtime image small. scratch or a slim base, no toolchain, no source.
  4. Implement both health endpoints. Liveness is not readiness.
  5. Handle SIGTERM. Graceful shutdown is what makes rolling updates safe.
  6. 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

  1. Configure the release profile with lto, strip, and panic = "abort", and compare binary size and startup time.
  2. Write a multi-stage Dockerfile that builds your Axum service and runs it as a non-root user.
  3. Add /health and /ready endpoints and a SIGTERM graceful-shutdown handler.
  4. Emit JSON logs with tracing_subscriber and filter them by level through RUST_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-hal traits 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:

LayerProvidesAttributeTypical use
stdHeap, threads, files, networking(default)Desktop, server.
allocBox, Vec, String, Arc#![no_std] + extern crate allocOS kernels, larger embedded.
coreSlices, 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-hal traits 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:

  1. #![no_main] — there is no standard main; #[entry] from cortex-m-rt defines the reset handler.
  2. panic_halt as _ — a #![no_std] binary must supply a panic handler; this one halts the CPU.
  3. main -> ! — embedded main never 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 Bookdocs.rust-embedded.org/book — the canonical tutorial.
  • embedded-hal docs — 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

  1. Write a #![no_std] function fn count_ones(bytes: &[u8]) -> u32 that counts set bits, and unit-test it with cargo test on your host.
  2. Install the thumbv7em-none-eabihf target and confirm a #![no_std] crate builds for it.
  3. 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, and rustfmt.
  • 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:

ResourceURLRead it for
The Rust Programming Language ("the Book")doc.rust-ownership.org/bookA guided, project-based introduction. The canonical starting point.
Rust by Exampledoc.rust-lang.org/rust-by-exampleRunnable snippets organized by topic — a quick reference.
The Rust Referencedoc.rust-lang.org/referencePrecise, definitive language semantics (not a tutorial).
The Rustonomicondoc.rust-lang.org/nomiconThe dark arts: unsafe, FFI, low-level memory.
The Async Bookrust-lang.github.io/async-bookHow async/await works under the hood.
The Cargo Bookdoc.rust-lang.org/cargoEverything about the build system and packaging.
The Edition Guidedoc.rust-lang.org/edition-guideWhat changed between the 2015, 2018, and 2021 editions.
The API Guidelinesrust-lang.github.io/api-guidelinesHow to design a Rust API that feels idiomatic.
std API docsdoc.rust-lang.org/stdThe 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 update keeps you current; rustup component add adds tools.
  • cargo — build, test, document, and publish. cargo check is the fast feedback loop; cargo build --release is for shipping.
  • rustfmt — the official formatter. Run cargo fmt so formatting is never a code-review discussion.
  • clippy — the linter. cargo clippy catches 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:

DomainCratePurpose
Serializationserde, serde_json(De)serialization, the universal format layer.
Errorsthiserror, anyhowLibrary and application error types.
Async runtimetokioThe dominant async runtime.
HTTP serveraxum, actix-webWeb frameworks.
HTTP clientreqwestHigh-level blocking and async client.
DatabasesqlxAsync, compile-time-checked SQL.
Loggingtracing, tracing-subscriberStructured logs and spans.
RandomrandThe randomness ecosystem.
RegexregexPerl-like regular expressions.
CLI parsingclapArgument parsing with derive macros.
Date/timechrono, timeDate and time arithmetic.
ParallelismrayonData-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

  1. Read the Book end to end. It is short for what it covers and builds a project (a grep clone) as it goes.
  2. Do rustlings. A set of small exercises that fill in the gaps the Book leaves to practice.
  3. Build something real. A CLI tool, a small web service, a game — a project of your own surfaces the questions no tutorial anticipates.
  4. Read good code. The standard library, serde, tokio, and axum are all well-written and educational.
  5. Write unsafe last. Most Rust programmers rarely need it; when you do, read the Rustonomicon first.
  6. Engage the community. The rust-users forum, 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.