rust.guide

rust.guide

The compiler is not in your way, it is doing your review

Rust gives you C speed with no garbage collector and no data races, and it charges for that up front, in arguments with the borrow checker. Every idea here arrives three ways: the Rust version, the Python version, and the JavaScript version, so you can see exactly what each rule is buying.

scope starts s &s &s &mut s
One value, one owner, and any number of shared borrows that overlap each other quite happily.
chapters
28
checked files
66
runtime dependencies
0
fn greet(name: &str) -> String

Why put up with the borrow checker

Rust gives you C speed with no garbage collector and no data races, and charges for it up front. Here is what you get for the price.

Python and JavaScript both decided that memory is the runtime's problem. That is a good decision, and it costs you a garbage collector, unpredictable pauses, and a large multiple on memory use. C and C++ decided memory is your problem, which is fast and has produced several decades of security advisories.

Rust takes a third position: memory is the compiler's problem. There is no garbage collector and no manual free. Instead the compiler tracks who owns each value and inserts the cleanup itself. The price is that you have to write code it can follow, and learning to do that is the first two weeks.

why_signatures.rs
//! Three functions, three different promises about what can go wrong.

/// Takes a borrowed string, gives back an owned one. The caller keeps their
/// value, and the signature says so without a word of documentation.
pub fn greet(name: &str) -> String {
    format!("Hello, {name}!")
}

/// The absence of an age is in the return type, so there is no way to forget
/// it. There is no null to check for and nothing to throw.
pub fn find_age(name: &str, people: &[(String, u32)]) -> Option<u32> {
    people
        .iter()
        .find(|(person, _)| person == name)
        .map(|(_, age)| *age)
}

/// This one can fail for a reason worth reporting, so it says which reasons.
pub fn parse_age(raw: &str) -> Result<u32, std::num::ParseIntError> {
    raw.trim().parse::<u32>()
}

fn main() {
    println!("{}", greet("world"));

    let people = vec![("ada".to_string(), 36)];
    match find_age("ada", &people) {
        Some(age) => println!("ada is {age}"),
        None => println!("nobody by that name"),
    }

    // The compiler will not let this line compile without handling both cases.
    println!("{:?}", parse_age("41"));
}

The same three functions in the languages you already use. Notice that neither signature says anything about what can go wrong, and neither says whether the function keeps what you gave it.

Python 3.12
# The same three functions in Python. Type hints document intent and enforce
# nothing at runtime, and none of them says what can go wrong.
def greet(name: str) -> str:
    return f"Hello, {name}!"


def find_age(name: str, people: dict[str, int]) -> int:
    # Raises KeyError, and the signature claims it returns an int. Callers
    # find out in production.
    return people[name]


def parse_age(raw: str) -> int:
    # Raises ValueError. Also invisible.
    return int(raw.strip())


def main() -> None:
    print(greet("world"))
    print(find_age("ada", {"ada": 36}))


if __name__ == "__main__":
    main()
JavaScript (Node 22)
// The same three functions in JavaScript. JSDoc helps an editor and stops
// nobody, and undefined is waiting at the end of every lookup.
/** @param {string} name */
export function greet(name) {
  return `Hello, ${name}!`;
}

/**
 * @param {string} name
 * @param {Record<string, number>} people
 */
export function findAge(name, people) {
  // undefined for a missing key, with no warning if you forget to check.
  // NaN is what you get if you then do arithmetic with it.
  return people[name];
}

export function parseAge(raw) {
  // Number("") is 0 and Number("x") is NaN, so this succeeds twice when it
  // should have failed.
  return Number(raw.trim());
}

console.log(greet("world"));

What the compiler is actually doing for you

memory

No garbage collector, no leaks

Every value has one owner, and when the owner goes out of scope the value is freed. The compiler works out where, at compile time, so the binary has no collector in it. Memory use is predictable enough to run on a microcontroller and there are no pauses to tune.

threads

Data races do not compile

The rule that prevents dangling pointers turns out to prevent data races too: you cannot have two references to something if one of them can write. Sharing across threads without a lock is a type error, not a bug that shows up under load.

nothing

No null, no exceptions

An absent value is Option<T> and a fallible one is Result<T, E>. Both are ordinary enums, both have to be unpacked before you can use what is inside, and both are impossible to forget by accident.

speed

Abstractions that cost nothing

An iterator chain compiles to the loop you would have written. A generic function is compiled once per type it is used with. Option<Box<T>> is one pointer, with the null case using the null pointer. You are not paying for the nice syntax.

Where it is actually being used

The clearest signal is not the greenfield projects, it is the tools you already run. ruff and uv replaced large parts of the Python tooling world and are written in Rust. So is the core of pydantic, and the fast paths of polars. On the JavaScript side, swc, turbopack, rolldown, biome and parts of Deno are Rust. Both ecosystems reached for it for the same reason: the hot loop was the bottleneck and the safety mattered.

It is also in the Linux kernel, in Windows, in Android's Bluetooth stack, and in Firefox's CSS engine. Google reported that memory safety bugs in Android fell from 76 percent of vulnerabilities to 24 percent over the years they moved new code to memory safe languages.

What you give up

What is harderWhat to do about it
Compile timescargo check while writing, and a release build only when measuring
The first fortnightThe borrow checker will refuse things that are fine. It gets much better once ownership clicks
Linked structures and graphsThey need Rc, RefCell or arena indices. This is genuinely more work than in Python
Quick scriptsRust is a poor choice for twenty lines of glue. Use Python and call Rust for the hot part
AsyncThe syntax is stable, the ecosystem is split across runtimes, and the error messages are the worst in the language
cargo new hello && cargo run

Install the toolchain and build something

One installer, one build tool, one formatter, one linter, one test runner. The tooling is the least controversial part of Rust.

Rust ships as one toolchain managed by rustup, which installs the compiler, the standard library, the documentation and Cargo. Cargo is the build tool, the package manager, the test runner and the documentation generator. There is no equivalent of choosing between pip, poetry, uv, venv and tox, or between npm, yarn, pnpm and bun. There is Cargo.

install.sh
# rustup installs the toolchain and is how you switch between versions.
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# Or through a package manager, if you prefer.
#   macOS     brew install rustup-init && rustup-init
#   Windows   winget install Rustlang.Rustup

rustc --version
cargo --version

# The components worth having on day one.
rustup component add clippy rustfmt rust-analyzer rust-src

# Pin a project to a version by writing a rust-toolchain.toml next to
# Cargo.toml. Everyone who builds it then gets the same compiler.
cat > rust-toolchain.toml <<'TOML'
[toolchain]
channel = "1.85"
components = ["clippy", "rustfmt"]
TOML
  1. Install rustup

    It puts a toolchain in ~/.cargo and adds it to your path. Updating later is rustup update, and a new stable release lands every six weeks.

  2. Add clippy and rustfmt

    rustfmt ends every formatting argument the way gofmt and black do. Clippy is a lint collection that knows a great deal of idiomatic Rust, and reading its suggestions is one of the faster ways to learn the language.

  3. Install rust-analyzer in your editor

    This matters more here than in most languages. Inferred types shown inline, the exact error under the cursor, and a one keystroke fix for many of them. Working without it is unnecessarily hard.

  4. Pin the version per project

    A rust-toolchain.toml next to Cargo.toml means everyone who builds the project gets the same compiler, including CI. Rust's stability guarantee makes upgrades boring, which is the point.

Your first program

src/main.rs
//! Every Rust program starts at fn main. Comments beginning with //! document
//! the file itself; /// documents the item below it.

fn main() {
    // println! is a macro, not a function. The ! is how you can tell, and it
    // is why the format string can be checked at compile time.
    let name = "world";
    println!("Hello, {name}!");

    // Bindings are immutable unless you ask for otherwise.
    let mut count = 0;
    count += 1;
    println!("count is {count}");

    // Types are inferred almost everywhere, and written where it helps.
    let numbers: Vec<i32> = (1..=5).collect();
    let total: i32 = numbers.iter().sum();
    println!("{numbers:?} adds up to {total}");
}
Terminal
$ cargo new hello
    Creating binary (application) `hello` package
      note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
$ cd hello
$ cargo run
   Compiling hello v0.1.0 (/home/you/hello)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.34s
     Running `target/debug/hello`
Hello, world!
$ cargo run --release
   Compiling hello v0.1.0 (/home/you/hello)
    Finished `release` profile [optimized] target(s) in 0.21s
     Running `target/release/hello`
Hello, world!
CommandWhat it does
cargo new namea new binary project, with git already initialised
cargo new name --liba new library
cargo checktype check only. The one to bind to a key
cargo builddebug build into target/debug
cargo build --releaseoptimised, and often 10 to 50 times faster to run
cargo run -- argsbuild and run. Everything after -- goes to your program
cargo testunit tests, integration tests and doc examples
cargo add serdeadd a dependency and write it into Cargo.toml
cargo doc --openbuild your documentation and open it
cargo clippylints. Run with -D warnings in CI
cargo fmtformat everything
let count: i32 = 42;

The syntax table

The translations worth keeping open for the first week. Everything in the Rust column compiles.

IdeaRustPythonJavaScript
Bindinglet count = 42;count = 42const count = 42;
Changeable bindinglet mut count = 42;count = 42let count = 42;
With a typelet count: i32 = 42;count: int = 42const count = 42;
Floatlet ratio: f64 = 3.14;ratio = 3.14const ratio = 3.14;
Text you borrowlet name: &str = "hi";name = "hi"const name = "hi";
Text you ownlet name = String::from("hi");name = "hi"const name = "hi";
Listlet v = vec![1, 2, 3];v = [1, 2, 3]const v = [1, 2, 3];
DictionaryHashMap::from([("k", 1)])m = {"k": 1}const m = new Map([["k", 1]]);
Tuplelet pair = (200, "OK");pair = (200, "OK")const pair = [200, "OK"];
Nothinglet v: Option<i32> = None;v = Noneconst v = null;
Somethinglet v = Some(7);v = 7const v = 7;
Might failfn f() -> Result<i32, E>def f() -> int (raises)function f() (throws)
Functionfn add(a: i32, b: i32) -> i32 {def add(a, b):function add(a, b) {
Anonymous function|x| x * 2lambda x: x * 2(x) => x * 2
Class-ishstruct User { } impl User { }class User:class User {}
Interface-ishtrait Summary { }class Summary(Protocol):duck typing
Sum typeenum Shape { Circle(f64), Point }Union / matchtagged objects
Loop over a listfor n in &nums {for n in nums:for (const n of nums) {
Map and filterv.iter().filter(..).map(..)[f(x) for x in v if g(x)]v.filter(g).map(f)
Printprintln!("{name}");print(name)console.log(name);
Formatformat!("{a} and {b:?}")f"{a} and {b!r}"`${a} and ${b}`
Comment// line, /// doc# line, """doc"""// line, /** doc */

Every line of the Rust column comes from this file, which compiles as it stands.

rosetta.rs
//! Every value in the comparison table, in one file that compiles.

use std::collections::HashMap;

#[derive(Debug, Clone, PartialEq)]
struct User {
    id: u32,
    name: String,
    admin: bool,
}

#[derive(Debug)]
enum Status {
    Idle,
    Running { since: u64 },
    Failed(String),
}

fn main() {
    // Integers carry their width. i32 is the default, usize indexes things.
    let count: i32 = 42;
    let big: u64 = 9_000_000_000;
    let ratio: f64 = 3.14;
    let yes: bool = true;
    let letter: char = 'r';

    // Two string types, and the difference matters. &str borrows, String owns.
    let borrowed: &str = "hi";
    let owned: String = String::from("hi");

    // A growable list, and a fixed size array.
    let numbers: Vec<i32> = vec![1, 2, 3];
    let fixed: [i32; 3] = [1, 2, 3];

    // A tuple, which is the closest thing to Python's tuple.
    let pair: (i32, &str) = (200, "OK");

    // The dictionary.
    let mut ages: HashMap<&str, u32> = HashMap::new();
    ages.insert("ada", 36);

    // No null. An absent value has a type that says it might be absent.
    let missing: Option<i32> = None;
    let present: Option<i32> = Some(7);

    // No exceptions. A fallible result has a type that says it might fail.
    let parsed: Result<i32, std::num::ParseIntError> = "12".parse();

    // A struct and an enum with data in its variants.
    let user = User { id: 1, name: owned.clone(), admin: false };
    let status = Status::Running { since: 1_700_000_000 };

    // A closure, which can capture what is around it.
    let double = |x: i32| x * 2;

    println!("{count} {big} {ratio} {yes} {letter}");
    println!("{borrowed} {owned} {numbers:?} {fixed:?} {pair:?}");
    println!("{ages:?} {missing:?} {present:?} {parsed:?}");
    println!("{user:?} {status:?} {}", double(21));
}

The four that catch everyone

let b = a; // a is no longer usable

Ownership and moves

Every value has exactly one owner. When the owner goes out of scope, the value is freed. That single rule replaces both the garbage collector and free().

Three rules, and everything else follows from them.

  1. Each value has one owner.
  2. There can only be one owner at a time.
  3. When the owner goes out of scope, the value is dropped.

The compiler works out where each drop goes and writes it into the binary. There is no collector deciding later and no free for you to forget.

ownership.rs
//! Ownership: every value has exactly one owner, and the owner decides when
//! it is freed. There is no garbage collector and no manual free.

#[derive(Debug)]
struct Config {
    name: String,
}

/// Taking a value by value takes ownership of it. The caller cannot use it
/// afterwards, which is what the compiler means by "moved".
fn consume(config: Config) -> usize {
    config.name.len()
}

/// Borrowing reads it without taking it. The caller keeps their value.
fn inspect(config: &Config) -> usize {
    config.name.len()
}

fn main() {
    // Move: two names cannot own the same heap allocation.
    let a = String::from("hello");
    let b = a;
    // println!("{a}"); // error[E0382]: borrow of moved value: `a`
    println!("b is {b}");

    // Copy: small values with a known size are duplicated instead of moved,
    // because copying them is as cheap as tracking the move would be.
    let x = 5;
    let y = x;
    println!("x is still {x}, y is {y}");

    // Clone is the explicit "I want a second one, and I accept the cost".
    let original = String::from("hello");
    let copy = original.clone();
    println!("{original} and {copy}");

    let config = Config { name: String::from("server") };

    // Borrow first: config is still ours afterwards.
    println!("length by borrow: {}", inspect(&config));

    // Then move: this is the last thing that can use config.
    println!("length by move: {}", consume(config));
    // println!("{config:?}"); // error[E0382]: borrow of moved value

    // Ownership can also come back out of a function.
    let returned = give_back(Config { name: String::from("client") });
    println!("got it back: {returned:?}");
}

fn give_back(config: Config) -> Config {
    config
}

What a move looks like when it goes wrong

Assigning a String to a second name does not copy it. The heap allocation has one owner, so the name moves and the old one stops being usable. Here is the compiler explaining that, which is a better teacher than a paragraph about it.

What the compiler says
$ cargo run
   Compiling ownership v0.1.0 (/home/you/ownership)
error[E0382]: borrow of moved value: `a`
 --> src/main.rs:5:16
  |
2 |     let a = String::from("hello");
  |         - move occurs because `a` has type `String`, which does not implement the `Copy` trait
3 |     let b = a;
  |             - value moved here
4 |
5 |     println!("{a}");
  |                ^ value borrowed here after move
  |
help: consider cloning the value if the performance cost is acceptable
  |
3 |     let b = a.clone();
  |              ++++++++
For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error
Python 3.12
# Python has no ownership, so the same object can be reached from several
# places and any of them can change it.
def add_item(cart: list[str], item: str) -> list[str]:
    cart.append(item)
    return cart


def main() -> None:
    original = ["book"]
    also_original = add_item(original, "pen")

    # Two names, one list. Changing either changes both, and nothing in the
    # signature warned you that add_item would keep the argument.
    also_original.append("desk")
    print(original)  # ['book', 'pen', 'desk']

    # The classic version of the same problem.
    def bad(items: list[str] = []) -> list[str]:
        items.append("x")
        return items

    print(bad(), bad())  # ['x'] ['x', 'x']

    # The fix is a convention rather than a rule: copy on the way in.
    def safe(items: list[str]) -> list[str]:
        copy = list(items)
        copy.append("x")
        return copy

    print(safe(original), original)


if __name__ == "__main__":
    main()
JavaScript (Node 22)
// JavaScript has no ownership either. const stops the binding from being
// reassigned and does nothing at all about the object it points to.
export function addItem(cart, item) {
  cart.push(item);
  return cart;
}

const original = ["book"];
const alsoOriginal = addItem(original, "pen");

// One array, two names. const did not help.
alsoOriginal.push("desk");
console.log(original); // [ 'book', 'pen', 'desk' ]

// Object.freeze is shallow, so this only protects the top level.
const config = Object.freeze({ limits: { max: 10 } });
config.limits.max = 999;
console.log(config.limits.max); // 999

// structuredClone gives a real deep copy, at the cost of copying everything
// every time, whether or not anything needed it.
const snapshot = structuredClone(config);
console.log(snapshot.limits.max);

Both let two names reach the same object, which is convenient right up to the moment something changes it under you. Neither signature says whether a function keeps what it was given.

Move, copy or clone

What happensWhenCost
MoveThe default for anything owning heap memory: String, Vec, Box, most structsCopies a few words of pointer, length and capacity. The heap data does not move
CopyTypes that implement Copy: integers, floats, bool, char, and tuples or arrays of themA bitwise duplicate. The original stays usable because copying is as cheap as tracking the move
CloneOnly when you write .clone()A real deep copy, and the reason it is explicit is so you can see it in the diff
  1. Prefer borrowing

    Most functions want to read a value, not keep it. Take &T and the whole question of moves goes away. This is the next chapter and it is the answer most of the time.

  2. Take ownership when you need to keep it

    A constructor storing the value, a function pushing it into a collection, a builder consuming itself. Taking T by value says "this is mine now" in the signature.

  3. Clone when the alternative is an argument with the compiler

    A clone in a setup path costs nothing anyone will measure. Fighting the borrow checker for an hour to avoid one is a bad trade. Clone, move on, and come back if a profile says it matters.

  4. Reach for Rc or Arc when there genuinely are several owners

    A cache, a graph, a value shared between threads. Reference counting is the escape hatch and it is covered later, along with what it costs.

Try it yourself

Write a function that takes a Vec<String>, returns the longest entry, and leaves the vector usable by the caller. Then write a second version that consumes the vector and returns the longest entry as an owned String with no allocation.

Hint: The first takes &[String] and returns &str. The second takes Vec<String> and uses into_iter, which yields owned Strings, so nothing has to be cloned.

Show one solution
ownership.rs
//! Ownership: every value has exactly one owner, and the owner decides when
//! it is freed. There is no garbage collector and no manual free.

#[derive(Debug)]
struct Config {
    name: String,
}

/// Taking a value by value takes ownership of it. The caller cannot use it
/// afterwards, which is what the compiler means by "moved".
fn consume(config: Config) -> usize {
    config.name.len()
}

/// Borrowing reads it without taking it. The caller keeps their value.
fn inspect(config: &Config) -> usize {
    config.name.len()
}

fn main() {
    // Move: two names cannot own the same heap allocation.
    let a = String::from("hello");
    let b = a;
    // println!("{a}"); // error[E0382]: borrow of moved value: `a`
    println!("b is {b}");

    // Copy: small values with a known size are duplicated instead of moved,
    // because copying them is as cheap as tracking the move would be.
    let x = 5;
    let y = x;
    println!("x is still {x}, y is {y}");

    // Clone is the explicit "I want a second one, and I accept the cost".
    let original = String::from("hello");
    let copy = original.clone();
    println!("{original} and {copy}");

    let config = Config { name: String::from("server") };

    // Borrow first: config is still ours afterwards.
    println!("length by borrow: {}", inspect(&config));

    // Then move: this is the last thing that can use config.
    println!("length by move: {}", consume(config));
    // println!("{config:?}"); // error[E0382]: borrow of moved value

    // Ownership can also come back out of a function.
    let returned = give_back(Config { name: String::from("client") });
    println!("got it back: {returned:?}");
}

fn give_back(config: Config) -> Config {
    config
}
fn first_word(text: &str) -> &str

References and the borrow checker

Any number of shared references, or exactly one exclusive reference, never both. That is the rule the whole language is built around.

Moving a value into every function that wants to look at it would be unbearable, so you can lend it instead. A reference borrows a value without taking ownership, and the borrow checker enforces one rule about them.

At any point, for any value, you can have either any number of &T shared references, or exactly one &mut T exclusive reference. Never both. It is worth reading &mut as "exclusive" rather than "mutable", because exclusivity is what it actually guarantees.

borrowing.rs
//! Borrowing: any number of shared references, or exactly one exclusive
//! reference, and never both at the same time.

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

    // Any number of shared borrows. Nobody can change the value while they
    // are alive, so all of them see the same thing.
    let first = &text;
    let second = &text;
    println!("{first} {second} {}", text.len());

    // The two shared borrows are not used after this point, so their
    // lifetimes have ended and an exclusive borrow is allowed here.
    let exclusive = &mut text;
    exclusive.push_str(", world");
    println!("{exclusive}");

    // Slices are borrows of part of something.
    let numbers = vec![1, 2, 3, 4, 5];
    let middle = &numbers[1..4];
    println!("{middle:?} out of {numbers:?}");

    let sentence = String::from("borrow checker");
    let word = first_word(&sentence);
    println!("first word: {word}");

    // A shared borrow inside a loop, so the vector is not consumed.
    let mut total = 0;
    for n in &numbers {
        total += n;
    }
    println!("{numbers:?} sums to {total}");

    // An exclusive borrow inside a loop, changing the elements in place.
    let mut scores = vec![1, 2, 3];
    for score in &mut scores {
        *score *= 10;
    }
    println!("{scores:?}");
}

/// Returning a borrow of the argument. The lifetime is inferred: the result
/// cannot outlive the string it points into.
fn first_word(text: &str) -> &str {
    match text.find(' ') {
        Some(index) => &text[..index],
        None => text,
    }
}

The error you will see most

What the compiler says
$ cargo run
error[E0502]: cannot borrow `text` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:21
  |
4 |     let first = &text;
  |                 ----- immutable borrow occurs here
5 |
6 |     let exclusive = &mut text;
  |                     ^^^^^^^^^ mutable borrow occurs here
7 |     println!("{first}");
  |               ------- immutable borrow later used here
For more information about this error, try `rustc --explain E0502`.

Read the three underlines in order and the message tells you the whole story: where the shared borrow starts, where the exclusive one tries to begin, and, crucially, where the shared one is still used later. That last line is the one that matters, because a borrow ends at its last use rather than at the closing brace.

Why the rule exists

BugHow the rule prevents it
Use after freeA reference cannot outlive what it points at, so there is nothing to dangle
Iterator invalidationIterating takes a shared borrow, so nothing can push to the vector while you are inside the loop. In Python and JavaScript this is a runtime surprise or silent wrong output
Aliasing bugsTwo names cannot reach a value when one of them can write, so nothing changes under you
Data racesThe same rule works across threads. Sharing without a lock does not compile, which is why Rust calls this fearless concurrency and means it
  1. &T to read

    The default for arguments. Take &str rather than &String, and &[T] rather than &Vec<T>, so callers can pass more things without converting.

  2. &mut T to change

    Exclusive for as long as it lives. If you need two exclusive borrows of different parts of one thing, split it: split_at_mut, or separate fields, which the compiler tracks individually.

  3. T when you are keeping it

    Storing it, consuming it, or sending it to another thread.

  4. When the checker refuses something you know is fine

    Usually the fix is to shorten a borrow rather than to reach for Rc or unsafe. Pull the borrow into a smaller block, or compute the value first and assign it after. Cloning is also an entirely respectable answer while you are learning.

Try it yourself

Write fn longest_word(text: &str) -> &str returning the longest whitespace separated word. Then try to write a version that returns a reference to a String created inside the function, and read the error it gives you carefully.

Hint: The second one cannot work: the String is dropped at the end of the function, so the reference would dangle. The compiler suggests returning String instead, and it is right.

Show one solution
borrowing.rs
//! Borrowing: any number of shared references, or exactly one exclusive
//! reference, and never both at the same time.

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

    // Any number of shared borrows. Nobody can change the value while they
    // are alive, so all of them see the same thing.
    let first = &text;
    let second = &text;
    println!("{first} {second} {}", text.len());

    // The two shared borrows are not used after this point, so their
    // lifetimes have ended and an exclusive borrow is allowed here.
    let exclusive = &mut text;
    exclusive.push_str(", world");
    println!("{exclusive}");

    // Slices are borrows of part of something.
    let numbers = vec![1, 2, 3, 4, 5];
    let middle = &numbers[1..4];
    println!("{middle:?} out of {numbers:?}");

    let sentence = String::from("borrow checker");
    let word = first_word(&sentence);
    println!("first word: {word}");

    // A shared borrow inside a loop, so the vector is not consumed.
    let mut total = 0;
    for n in &numbers {
        total += n;
    }
    println!("{numbers:?} sums to {total}");

    // An exclusive borrow inside a loop, changing the elements in place.
    let mut scores = vec![1, 2, 3];
    for score in &mut scores {
        *score *= 10;
    }
    println!("{scores:?}");
}

/// Returning a borrow of the argument. The lifetime is inferred: the result
/// cannot outlive the string it points into.
fn first_word(text: &str) -> &str {
    match text.find(' ') {
        Some(index) => &text[..index],
        None => text,
    }
}
fn longest<'a>(l: &'a str, r: &'a str) -> &'a str

Lifetimes

Lifetimes are the compiler's name for how long a borrow is valid. Almost all of them are inferred, and the ones you write are a smaller set than the syntax suggests.

Every reference has a lifetime. The overwhelming majority are worked out by the compiler and never appear in your code. Annotations are needed in exactly one situation: when a function returns a reference and the compiler cannot tell which input it came from.

An annotation does not change how long anything lives. It describes a relationship that already exists, so the compiler can check the callers.

What the compiler says
$ cargo check
error[E0106]: missing lifetime specifier
 --> src/lib.rs:1:41
  |
1 | fn longest(left: &str, right: &str) -> &str {
  |                  ----         ----     ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `left` or `right`
help: consider introducing a named lifetime parameter
  |
1 | fn longest<'a>(left: &'a str, right: &'a str) -> &'a str {
  |           ++++        ++             ++           ++
For more information about this error, try `rustc --explain E0106`.

The message spells out the problem and then writes the fix for you. 'a here means: the returned reference is valid for whichever of the two inputs is valid for less time.

lifetimes.rs
//! Lifetimes are the compiler's name for how long a borrow is valid. Most of
//! them are inferred. The ones you write down are the ones where a function
//! returns a borrow and the compiler cannot tell which input it came from.

/// Two inputs, one borrowed output, so the relationship has to be stated:
/// the result lives no longer than whichever argument lives less long.
fn longest<'a>(left: &'a str, right: &'a str) -> &'a str {
    if left.len() >= right.len() { left } else { right }
}

/// A struct that borrows rather than owns. It cannot outlive the text it
/// points into, and the lifetime parameter is how that is written down.
#[derive(Debug)]
struct Excerpt<'a> {
    part: &'a str,
}

impl<'a> Excerpt<'a> {
    fn new(text: &'a str) -> Self {
        let end = text.find('.').unwrap_or(text.len());
        Excerpt { part: &text[..end] }
    }

    /// The elision rules cover this one: an argument of &self means the
    /// output borrows from self, so no annotation is needed.
    fn announce(&self, note: &str) -> &str {
        println!("note: {note}");
        self.part
    }
}

fn main() {
    let title = String::from("the borrow checker");
    let subtitle = String::from("a short argument");
    println!("longest: {}", longest(&title, &subtitle));

    let article = String::from("Rust has no runtime. That is the point.");
    let excerpt = Excerpt::new(&article);
    println!("{:?}", excerpt.announce("first sentence"));

    // 'static means the value lives for the whole program. String literals
    // are baked into the binary, so they qualify.
    let forever: &'static str = "compiled in";
    println!("{forever}");
}

The elision rules

The reason you rarely write lifetimes is that three rules cover the common cases. The compiler applies them in order, and if they resolve every output lifetime, nothing needs annotating.

RuleMeaning
1Each reference argument gets its own lifetime parameter
2If there is exactly one input lifetime, it is given to every output
3If one of the arguments is &self or &mut self, its lifetime is given to every output

Rule two is why fn first_word(text: &str) -> &str needs nothing. Rule three is why methods almost never need anything. The longest function above breaks both, because it has two input references and no self, which is exactly when you have to say which one the output came from.

Structs that borrow

A struct holding a reference needs a lifetime parameter, and it means the struct cannot outlive what it points into. This is a genuinely useful pattern for zero copy parsing: a struct of &str slices into one input buffer, with no allocation at all.

It is also the thing most likely to spread lifetime parameters through your code until everything has one. When that starts happening, the usual answer is to own the data instead: use String rather than &str and accept the allocation. Reach for borrowed structs when you have measured that the copying matters.

enum Shape { Circle { radius: f64 }, Point }

Structs and enums

Structs group data. Enums say a value is one of several shapes. Together they replace classes, interfaces, unions, null and exceptions.

Rust has no classes and no inheritance. A struct holds data, an impl block holds its methods, and shared behaviour comes from traits rather than from a base class. That separation takes a little getting used to and removes a whole category of design argument.

types.rs
//! Structs and enums. Between them they cover what classes, interfaces,
//! unions and null are used for elsewhere.

/// A named struct. #[derive] asks the compiler to write the boring
/// implementations: Debug for printing, Clone for duplication, PartialEq
/// for ==.
#[derive(Debug, Clone, PartialEq)]
pub struct Rectangle {
    width: f64,
    height: f64,
}

/// A tuple struct, for a wrapper that needs a distinct type but no field
/// names. This is how you stop metres being added to seconds.
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Metres(pub f64);

impl Rectangle {
    /// An associated function. No self, so it is called on the type:
    /// Rectangle::new(3.0, 4.0).
    pub fn new(width: f64, height: f64) -> Self {
        Self { width, height }
    }

    /// A method taking &self borrows, so the caller keeps the rectangle.
    pub fn area(&self) -> f64 {
        self.width * self.height
    }

    /// &mut self borrows exclusively, so this can change the value.
    pub fn scale(&mut self, factor: f64) {
        self.width *= factor;
        self.height *= factor;
    }

    /// self by value consumes the rectangle. Useful for builders and for
    /// conversions that should not leave the original around.
    pub fn into_square(self) -> Rectangle {
        let side = self.width.max(self.height);
        Rectangle::new(side, side)
    }
}

/// An enum where each variant carries different data. This is the feature
/// Python and JavaScript have no direct equivalent of, and it is the reason
/// Rust needs neither null nor exceptions.
#[derive(Debug)]
pub enum Shape {
    Circle { radius: f64 },
    Rect(Rectangle),
    Point,
}

impl Shape {
    pub fn area(&self) -> f64 {
        // The match must cover every variant. Add one later and every match
        // that forgot it stops compiling, which is the whole point.
        match self {
            Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
            Shape::Rect(rect) => rect.area(),
            Shape::Point => 0.0,
        }
    }
}

/// Option and Result are ordinary enums from the standard library. Nothing
/// about them is special, which is worth knowing when they start to feel
/// like language features.
pub fn describe(value: Option<i32>) -> String {
    match value {
        Some(n) if n > 0 => format!("positive: {n}"),
        Some(n) => format!("not positive: {n}"),
        None => String::from("nothing"),
    }
}

fn main() {
    let mut rect = Rectangle::new(3.0, 4.0);
    println!("area {}", rect.area());

    rect.scale(2.0);
    println!("scaled {rect:?}");

    let square = rect.clone().into_square();
    println!("square {square:?}, original still here {rect:?}");

    let shapes = vec![
        Shape::Circle { radius: 1.0 },
        Shape::Rect(Rectangle::new(2.0, 3.0)),
        Shape::Point,
    ];
    for shape in &shapes {
        println!("{shape:?} has area {:.2}", shape.area());
    }

    println!("{}", describe(Some(3)));
    println!("{}", describe(None));

    let distance = Metres(4.0);
    println!("{distance:?}");
}
Python 3.12
from dataclasses import dataclass, replace
from enum import Enum, auto
from math import pi, sqrt


class Direction(Enum):
    NORTH = auto()
    SOUTH = auto()
    EAST = auto()
    WEST = auto()


@dataclass(frozen=True)
class Circle:
    radius: float


@dataclass(frozen=True)
class Rectangle:
    width: float
    height: float


@dataclass(frozen=True)
class Triangle:
    a: float
    b: float
    c: float


Shape = Circle | Rectangle | Triangle


def area(shape: Shape) -> float:
    # A missing branch is a runtime surprise unless a type checker is run
    # separately. The Rust compiler refuses to build the program.
    match shape:
        case Circle(radius):
            return pi * radius * radius
        case Rectangle(width, height):
            return width * height
        case Triangle(a, b, c):
            s = (a + b + c) / 2
            return sqrt(s * (s - a) * (s - b) * (s - c))
    raise ValueError(f"unknown shape: {shape}")


@dataclass(frozen=True)
class Employee:
    name: str
    email: str
    salary: int


def raise_salary(amount: int, employee: Employee) -> Employee:
    # frozen=True plus replace() is the closest Python gets to Rust's struct update syntax.
    return replace(employee, salary=employee.salary + amount)
JavaScript (Node 22)
export const Direction = Object.freeze({
  North: "North",
  South: "South",
  East: "East",
  West: "West",
});

// A tag field stands in for a real sum type.
export const circle = (radius) => ({ kind: "circle", radius });
export const rectangle = (width, height) => ({ kind: "rectangle", width, height });
export const triangle = (a, b, c) => ({ kind: "triangle", a, b, c });

export function area(shape) {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle": {
      const s = (shape.a + shape.b + shape.c) / 2;
      return Math.sqrt(s * (s - shape.a) * (s - shape.b) * (s - shape.c));
    }
    // Forget a case and the answer is quietly undefined.
    default:
      throw new Error(`unknown shape: ${shape.kind}`);
  }
}

// Spread copies one level. Anything nested is still shared with the
// original, which is where "why did that change" bugs come from.
export const raiseSalary = (amount, employee) => ({
  ...employee,
  salary: employee.salary + amount,
});

A frozen dataclass is close to a struct. Neither language has an equivalent of an enum whose variants carry different data, which is why both end up with tagged dictionaries and a comment.

The three receivers

SignatureMeansCaller keeps the value
fn area(&self)borrow to readyes
fn scale(&mut self)borrow exclusively to changeyes
fn into_square(self)take ownership and consumeno
fn new() -> Selfno receiver, so it is called on the typenot applicable

The naming convention follows the receiver. as_ is a cheap borrow to borrow conversion, to_ allocates, and into_ consumes. as_str, to_string and into_bytes tell you their cost from the name alone.

Enums are the feature you are missing

An enum variant can carry data, and different variants can carry different data. That is how Option and Result work, and it is why Rust needs neither null nor exceptions: both are ordinary enums from the standard library, with no special support in the compiler.

The part that pays off later is exhaustiveness. Add a variant to an enum and every match that does not handle it stops compiling. In a large codebase that turns "find everywhere that handles a payment status" from a search into a build.

What the compiler says
$ cargo check
error[E0004]: non-exhaustive patterns: `Event::Quit` not covered
  --> src/main.rs:12:11
   |
12 |     match event {
   |           ^^^^^ pattern `Event::Quit` not covered
   |
note: `Event` defined here
  --> src/main.rs:5:6
   |
5  | enum Event {
   |      ^^^^^
8  |     Quit,
   |     ---- not covered
   = note: the matched value is of type `Event`
help: ensure the match is exhaustive by adding a match arm with a pattern
   |
15 ~         Event::Scroll(n) => format!("scroll {n}"),
16 +         Event::Quit => todo!(),
   |
For more information about this error, try `rustc --explain E0004`.
match event { Event::Key(c) => ... }

Pattern matching

match is an expression, it must cover every case, and patterns appear in let, function arguments, for loops and closures too.

Matching is how you take a value apart, and testing its shape is the same operation as destructuring it. Because match is an expression it produces a value, so it is used where other languages would assign in each branch of an if.

matching.rs
//! Pattern matching. match is an expression, it must cover every case, and
//! the compiler enforces that.

#[derive(Debug)]
enum Event {
    Click { x: i32, y: i32 },
    Key(char),
    Scroll(i32),
    Quit,
}

fn handle(event: &Event) -> String {
    match event {
        // Destructure a struct variant, and bind its fields by name.
        Event::Click { x, y } if *x == *y => format!("click on the diagonal at {x}"),
        Event::Click { x, y } => format!("click at {x},{y}"),

        // Match several literals in one arm with |.
        Event::Key('q' | 'Q') => String::from("quit key"),
        Event::Key(c) => format!("key {c}"),

        // Ranges work in patterns too.
        Event::Scroll(amount @ 1..=10) => format!("small scroll of {amount}"),
        Event::Scroll(amount) => format!("scroll of {amount}"),

        Event::Quit => String::from("quit"),
    }
}

fn main() {
    let events = vec![
        Event::Click { x: 3, y: 3 },
        Event::Key('a'),
        Event::Scroll(4),
        Event::Quit,
    ];
    for event in &events {
        println!("{}", handle(event));
    }

    // if let, for when only one case is interesting.
    let maybe = Some(5);
    if let Some(n) = maybe {
        println!("got {n}");
    }

    // let else, for when the uninteresting case means leaving early. This is
    // the one that removes most of the nesting from real code.
    let text = "42";
    let Ok(number) = text.parse::<i32>() else {
        println!("not a number");
        return;
    };
    println!("parsed {number}");

    // while let, to keep pulling until there is nothing left.
    let mut stack = vec![1, 2, 3];
    while let Some(top) = stack.pop() {
        println!("popped {top}");
    }

    // Destructuring in a let, and in function arguments.
    let (a, b) = (1, 2);
    let [first, .., last] = [10, 20, 30, 40];
    println!("{a} {b} {first} {last}");

    // matches! is a small macro for "does this fit the shape", as a bool.
    println!("{}", matches!(events[0], Event::Click { .. }));
}
Python 3.12
def describe(items: list[str]) -> str:
    match items:
        case []:
            return "nothing"
        case [x]:
            return f"just {x}"
        case [x, y]:
            return f"{x} and {y}"
        case [x, *rest]:
            return f"{x} and {len(rest)} more"
    return "unreachable"


def http_message(code: int) -> str:
    match code:
        case 200:
            return "OK"
        case 404:
            return "Not Found"
        case 500:
            return "Server Error"
        case _:
            return f"Status {code}"


def distance(a: tuple[float, float], b: tuple[float, float]) -> float:
    (x1, y1), (x2, y2) = a, b
    return ((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5


def grade(score: int) -> str:
    if score >= 90:
        return "A"
    if score >= 80:
        return "B"
    if score >= 70:
        return "C"
    return "F"
JavaScript (Node 22)
// There is no pattern matching, so shapes are checked by hand.
export function describe(items) {
  if (items.length === 0) return "nothing";
  if (items.length === 1) return `just ${items[0]}`;
  if (items.length === 2) return `${items[0]} and ${items[1]}`;
  const [first, ...rest] = items;
  return `${first} and ${rest.length} more`;
}

export function httpMessage(code) {
  switch (code) {
    case 200:
      return "OK";
    case 404:
      return "Not Found";
    case 500:
      return "Server Error";
    default:
      return `Status ${code}`;
  }
}

export function distance([x1, y1], [x2, y2]) {
  return Math.hypot(x2 - x1, y2 - y1);
}

export function grade(score) {
  if (score >= 90) return "A";
  if (score >= 80) return "B";
  if (score >= 70) return "C";
  return "F";
}

Python's match, from 3.10, is close. Neither is exhaustive, so forgetting a case is silence at runtime rather than an error at build time.

The forms worth knowing

FormFor
match value { ... }several cases, all of which must be covered
if let Some(x) = value { }one interesting case, ignore the rest
let Some(x) = value else { return; }the uninteresting case means leaving early
while let Some(x) = stack.pop()keep going until there is nothing left
let (a, b) = pair;destructuring where a match would be noise
matches!(value, Pattern)does this fit the shape, as a bool
x @ 1..=9bind the value and test a range at once
Some(x) if x > 0a guard, for conditions patterns cannot express
'a' | 'A'several literals in one arm
[first, .., last]slice patterns, matching by position

Try it yourself

Write an enum for an HTTP response with variants for a success carrying a body, a redirect carrying a location, and an error carrying a status code and a message. Then write a function that turns one into a log line, and add a fourth variant afterwards to see what the compiler does.

Hint: Do not add a wildcard arm. The error you get after adding the variant is the feature.

Show one solution
matching.rs
//! Pattern matching. match is an expression, it must cover every case, and
//! the compiler enforces that.

#[derive(Debug)]
enum Event {
    Click { x: i32, y: i32 },
    Key(char),
    Scroll(i32),
    Quit,
}

fn handle(event: &Event) -> String {
    match event {
        // Destructure a struct variant, and bind its fields by name.
        Event::Click { x, y } if *x == *y => format!("click on the diagonal at {x}"),
        Event::Click { x, y } => format!("click at {x},{y}"),

        // Match several literals in one arm with |.
        Event::Key('q' | 'Q') => String::from("quit key"),
        Event::Key(c) => format!("key {c}"),

        // Ranges work in patterns too.
        Event::Scroll(amount @ 1..=10) => format!("small scroll of {amount}"),
        Event::Scroll(amount) => format!("scroll of {amount}"),

        Event::Quit => String::from("quit"),
    }
}

fn main() {
    let events = vec![
        Event::Click { x: 3, y: 3 },
        Event::Key('a'),
        Event::Scroll(4),
        Event::Quit,
    ];
    for event in &events {
        println!("{}", handle(event));
    }

    // if let, for when only one case is interesting.
    let maybe = Some(5);
    if let Some(n) = maybe {
        println!("got {n}");
    }

    // let else, for when the uninteresting case means leaving early. This is
    // the one that removes most of the nesting from real code.
    let text = "42";
    let Ok(number) = text.parse::<i32>() else {
        println!("not a number");
        return;
    };
    println!("parsed {number}");

    // while let, to keep pulling until there is nothing left.
    let mut stack = vec![1, 2, 3];
    while let Some(top) = stack.pop() {
        println!("popped {top}");
    }

    // Destructuring in a let, and in function arguments.
    let (a, b) = (1, 2);
    let [first, .., last] = [10, 20, 30, 40];
    println!("{a} {b} {first} {last}");

    // matches! is a small macro for "does this fit the shape", as a bool.
    println!("{}", matches!(events[0], Event::Click { .. }));
}
fn make_adder(n: i32) -> impl Fn(i32) -> i32

Functions and closures

Expressions rather than statements, closures that capture in three different ways, and a return type that says which.

Almost everything in Rust is an expression. if, match, loop and a plain block all produce values, which is why so little Rust code declares a variable and then assigns to it in branches.

functions.rs
//! Functions, closures, and the three ways a closure can capture.

/// The last expression is the return value. A semicolon after it would turn
/// it into a statement, and the function would return ().
fn double(x: i32) -> i32 {
    x * 2
}

/// return exists, and is for leaving early.
fn classify(n: i32) -> &'static str {
    if n < 0 {
        return "negative";
    }
    match n {
        0 => "zero",
        1..=9 => "small",
        _ => "large",
    }
}

/// if is an expression, so this is one binding rather than four lines of
/// assignment.
fn bmi_verdict(weight_kg: f64, height_m: f64) -> (&'static str, f64) {
    let bmi = weight_kg / (height_m * height_m);
    let verdict = if bmi <= 18.5 {
        "underweight"
    } else if bmi <= 25.0 {
        "normal"
    } else {
        "overweight"
    };
    (verdict, bmi)
}

/// Taking a function as an argument. impl Fn in argument position means "any
/// closure with this shape", resolved at compile time with no indirection.
fn apply_twice(f: impl Fn(i32) -> i32, value: i32) -> i32 {
    f(f(value))
}

/// Returning a closure. It has no name, so the return type is impl Fn, and
/// move tells the closure to take ownership of what it captured.
fn make_adder(n: i32) -> impl Fn(i32) -> i32 {
    move |x| x + n
}

fn main() {
    println!("{}", double(21));
    println!("{}", classify(-1));

    let (verdict, bmi) = bmi_verdict(70.0, 1.8);
    println!("{verdict} at {bmi:.1}");

    // A closure borrowing what it uses.
    let factor = 3;
    let triple = |x| x * factor;
    println!("{}", triple(5));
    println!("factor is still usable: {factor}");

    // A closure that changes what it captured needs to be mut itself.
    let mut count = 0;
    let mut bump = || count += 1;
    bump();
    bump();
    println!("bumped to {count}");

    println!("{}", apply_twice(double, 3));
    println!("{}", apply_twice(|x| x + 10, 3));

    let add_five = make_adder(5);
    println!("{}", add_five(10));

    // A block is an expression, so this is a way to scope temporary work.
    let result = {
        let a = 2;
        let b = 3;
        a * b
    };
    println!("{result}");
}
Python 3.12
def double(x: int) -> int:
    return x * 2


def area(width: float, height: float) -> float:
    return width * height


def classify(n: int) -> str:
    if n < 0:
        return "negative"
    if n == 0:
        return "zero"
    if n < 10:
        return "small"
    return "large"


def bmi_tell(weight: float, height: float) -> str:
    bmi = weight / height**2
    if bmi <= 18.5:
        return f"underweight, bmi {bmi}"
    if bmi <= 25.0:
        return f"normal, bmi {bmi}"
    return f"overweight, bmi {bmi}"


def cylinder_area(radius: float, height: float) -> float:
    side = 2 * 3.141592653589793 * radius * height
    cap = 3.141592653589793 * radius**2
    return side + 2 * cap


def add_one_to_all(xs: list[int]) -> list[int]:
    return [n + 1 for n in xs]
JavaScript (Node 22)
export const double = (x) => x * 2;

export const area = (width, height) => width * height;

export function classify(n) {
  if (n < 0) return "negative";
  if (n === 0) return "zero";
  if (n < 10) return "small";
  return "large";
}

export function bmiTell(weight, height) {
  const bmi = weight / height ** 2;
  if (bmi <= 18.5) return `underweight, bmi ${bmi}`;
  if (bmi <= 25.0) return `normal, bmi ${bmi}`;
  return `overweight, bmi ${bmi}`;
}

export function cylinderArea(radius, height) {
  const side = 2 * Math.PI * radius * height;
  const cap = Math.PI * radius ** 2;
  return side + 2 * cap;
}

export const addOneToAll = (xs) => xs.map((n) => n + 1);

The three closure traits

A closure captures the variables around it, and how it captures them determines which trait it implements. You rarely choose: the compiler picks the least demanding one that works, and the traits exist so that functions taking closures can say what they need.

TraitCaptures byCan be called
Fn&T, shared borrowmany times, from several places at once
FnMut&mut T, exclusive borrowmany times, one at a time
FnOnceT, by valueonce, because calling it consumes what it took

move before a closure forces it to take ownership of everything it captures. That is what you need when the closure outlives the current scope: returning it, or sending it to a thread, or storing it in a struct.

*counts.entry(word).or_default() += 1;

Vec, HashMap, and the two string types

The collections you will use every day, and the String versus &str question that catches everyone in their first week.

collections.rs
//! Vec, HashMap, and the two string types.

use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};

fn main() {
    // Vec is the growable list. Indexing panics on a bad index; get returns
    // an Option, which is the version that cannot surprise you.
    let mut numbers = vec![3, 1, 2];
    numbers.push(4);
    numbers.sort();
    println!("{numbers:?} first={:?} tenth={:?}", numbers.first(), numbers.get(9));

    // Removing while iterating is not allowed, so retain does it in one pass.
    let mut names = vec!["ada", "grace", "alan", "barbara"];
    names.retain(|name| name.len() > 3);
    println!("{names:?}");

    // HashMap. entry().or_insert() is the counting idiom, and or_default()
    // saves writing the zero.
    let mut counts: HashMap<&str, u32> = HashMap::new();
    for word in ["a", "b", "a", "c", "a"] {
        *counts.entry(word).or_default() += 1;
    }
    let mut pairs: Vec<_> = counts.iter().collect();
    pairs.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
    println!("{pairs:?}");

    // get returns Option<&V>, so a missing key is a case rather than a crash.
    println!("{:?} {:?}", counts.get("a"), counts.get("z"));

    // BTreeMap keeps its keys in order, which HashMap deliberately does not.
    let sorted: BTreeMap<_, _> = counts.iter().collect();
    println!("{sorted:?}");

    // A set, and a queue with cheap pushes at both ends.
    let unique: HashSet<i32> = vec![1, 2, 2, 3].into_iter().collect();
    let mut queue: VecDeque<i32> = VecDeque::new();
    queue.push_back(1);
    queue.push_front(0);
    println!("{} unique, queue {queue:?}", unique.len());

    strings();
}

/// The two string types, which is the part that trips people up.
fn strings() {
    // String owns its bytes on the heap and can grow.
    let mut owned = String::from("hello");
    owned.push_str(", world");
    owned.push('!');

    // &str borrows a run of bytes that somebody else owns. A literal is a
    // &'static str pointing into the binary.
    let borrowed: &str = "just reading";
    let slice: &str = &owned[0..5];

    println!("{owned} / {borrowed} / {slice}");

    // Rust strings are UTF-8, so there is no indexing by character. Ask for
    // what you actually want: bytes, or chars, or graphemes from a crate.
    let text = "café";
    println!("{} bytes, {} chars", text.len(), text.chars().count());
    println!("{:?}", text.chars().rev().collect::<String>());

    // Take &str in arguments and return String when you produce something
    // new. That way callers can pass either without allocating.
    println!("{}", shout("quietly"));
}

fn shout(text: &str) -> String {
    format!("{}!", text.to_uppercase())
}
Python 3.12
from itertools import count, islice

primes = [2, 3, 5, 7, 11]

# Python lists are arrays, so prepending copies the whole thing.
with_one = [1, *primes]

countdown = list(range(10, 0, -1))
evens = list(range(0, 21, 2))
letters = [chr(c) for c in range(ord("a"), ord("f"))]

# There is no lazy list literal, so an infinite sequence needs itertools.
squares = [n * n for n in islice(count(1), 10)]

pythagorean = [
    (a, b, c)
    for c in range(1, 21)
    for b in range(1, c + 1)
    for a in range(1, b + 1)
    if a * a + b * b == c * c
]


def initials(sentence: str) -> str:
    return "".join(word[0] for word in sentence.split())


def summary(xs: list[int]) -> tuple[int, int, list[int], list[int]]:
    total = sum(xs)
    # Slicing and reversed() copy, but sort() and append() do not: some list
    # methods return a new list and some change the one you have.
    return (len(xs), total, xs[:3], list(reversed(xs)))
JavaScript (Node 22)
export const primes = [2, 3, 5, 7, 11];

export const withOne = [1, ...primes];

export const countdown = Array.from({ length: 10 }, (_, i) => 10 - i);
export const evens = Array.from({ length: 11 }, (_, i) => i * 2);
export const letters = Array.from({ length: 5 }, (_, i) =>
  String.fromCharCode(97 + i),
);

// Nothing is lazy, so an endless sequence needs a generator.
function* naturals() {
  for (let n = 1; ; n++) yield n;
}

export const squares = [];
for (const n of naturals()) {
  if (squares.length === 10) break;
  squares.push(n * n);
}

export const pythagorean = [];
for (let c = 1; c <= 20; c++) {
  for (let b = 1; b <= c; b++) {
    for (let a = 1; a <= b; a++) {
      if (a * a + b * b === c * c) pythagorean.push([a, b, c]);
    }
  }
}

export const initials = (sentence) =>
  sentence
    .split(/\s+/)
    .filter(Boolean)
    .map((word) => word[0])
    .join("");

// sort() and reverse() change the array in place and also return it, which
// is the source of a great many surprises.
export const summary = (xs) => [xs.length, xs.reduce((a, b) => a + b, 0), xs.slice(0, 3), [...xs].reverse()];

Which collection

TypeForNote
Vec<T>a growable listthe default. Contiguous, so iteration is fast
VecDeque<T>a queuecheap push and pop at both ends
HashMap<K, V>key to valueno order at all, and the order changes between runs
BTreeMap<K, V>key to value, sortedordered iteration and range queries
HashSet<T>, BTreeSet<T>membershipsame trade as the maps
BinaryHeap<T>always take the largesta priority queue
&[T]borrowing part of a listwhat functions should take instead of &Vec
[T; N]a fixed size arrayon the stack, size known at compile time

String and &str

String owns bytes on the heap and can grow. &str borrows a run of bytes somebody else owns. They are not two ways of saying the same thing, they are an owner and a view.

You haveYou want &strYou want String
String&s or s.as_str()it already is one
&strit already is ones.to_string() or String::from(s)
&String&**s, or just pass it, deref does its.clone()
A numbernot directlyn.to_string()
Several piecesnot directlyformat!("{a}{b}")

Try it yourself

Write a function that takes a slice of strings and returns a HashMap from first letter to the list of words beginning with it. Then change it to return a BTreeMap and notice which one makes the tests easier to write.

Hint: entry(letter).or_insert_with(Vec::new).push(word). The BTreeMap version has a defined iteration order, which is why it is easier to assert on.

Show one solution
collections.rs
//! Vec, HashMap, and the two string types.

use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};

fn main() {
    // Vec is the growable list. Indexing panics on a bad index; get returns
    // an Option, which is the version that cannot surprise you.
    let mut numbers = vec![3, 1, 2];
    numbers.push(4);
    numbers.sort();
    println!("{numbers:?} first={:?} tenth={:?}", numbers.first(), numbers.get(9));

    // Removing while iterating is not allowed, so retain does it in one pass.
    let mut names = vec!["ada", "grace", "alan", "barbara"];
    names.retain(|name| name.len() > 3);
    println!("{names:?}");

    // HashMap. entry().or_insert() is the counting idiom, and or_default()
    // saves writing the zero.
    let mut counts: HashMap<&str, u32> = HashMap::new();
    for word in ["a", "b", "a", "c", "a"] {
        *counts.entry(word).or_default() += 1;
    }
    let mut pairs: Vec<_> = counts.iter().collect();
    pairs.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
    println!("{pairs:?}");

    // get returns Option<&V>, so a missing key is a case rather than a crash.
    println!("{:?} {:?}", counts.get("a"), counts.get("z"));

    // BTreeMap keeps its keys in order, which HashMap deliberately does not.
    let sorted: BTreeMap<_, _> = counts.iter().collect();
    println!("{sorted:?}");

    // A set, and a queue with cheap pushes at both ends.
    let unique: HashSet<i32> = vec![1, 2, 2, 3].into_iter().collect();
    let mut queue: VecDeque<i32> = VecDeque::new();
    queue.push_back(1);
    queue.push_front(0);
    println!("{} unique, queue {queue:?}", unique.len());

    strings();
}

/// The two string types, which is the part that trips people up.
fn strings() {
    // String owns its bytes on the heap and can grow.
    let mut owned = String::from("hello");
    owned.push_str(", world");
    owned.push('!');

    // &str borrows a run of bytes that somebody else owns. A literal is a
    // &'static str pointing into the binary.
    let borrowed: &str = "just reading";
    let slice: &str = &owned[0..5];

    println!("{owned} / {borrowed} / {slice}");

    // Rust strings are UTF-8, so there is no indexing by character. Ask for
    // what you actually want: bytes, or chars, or graphemes from a crate.
    let text = "café";
    println!("{} bytes, {} chars", text.len(), text.chars().count());
    println!("{:?}", text.chars().rev().collect::<String>());

    // Take &str in arguments and return String when you produce something
    // new. That way callers can pass either without allocating.
    println!("{}", shout("quietly"));
}

fn shout(text: &str) -> String {
    format!("{}!", text.to_uppercase())
}
v.iter().filter(..).map(..).sum()

Iterators

Lazy, composable, and compiled down to the loop you would have written by hand. This is where Rust stops feeling like a systems language.

An iterator is anything with a next method returning Option<Item>. Adapters like map and filter wrap one iterator in another and do no work. Nothing happens until a consumer like sum, collect or a for loop starts pulling.

The optimiser then flattens the whole chain. A five step pipeline over a vector compiles to a single loop with no intermediate allocations, which is why nobody in Rust argues that you should write the loop by hand for speed.

iterators.rs
//! Iterators. They are lazy, they compile down to the loop you would have
//! written by hand, and they are the main reason Rust code reads well.

#[derive(Debug, Clone)]
struct Order {
    item: String,
    quantity: u32,
    price: f64,
}

fn main() {
    let numbers: Vec<i32> = (1..=10).collect();

    // Nothing happens until something consumes the chain. map and filter
    // build a description; sum runs it, once, in one pass.
    let total: i32 = numbers.iter().filter(|n| *n % 2 == 0).map(|n| n * n).sum();
    println!("{total}");

    // Three ways to iterate, and the difference is ownership.
    //   iter()      yields &T,     the collection is untouched
    //   iter_mut()  yields &mut T, the elements can be changed
    //   into_iter() yields T,      the collection is consumed
    let mut words = vec![String::from("one"), String::from("two")];
    for word in words.iter() {
        print!("{word} ");
    }
    for word in words.iter_mut() {
        word.push('!');
    }
    println!("{words:?}");

    let orders = vec![
        Order { item: "book".into(), quantity: 2, price: 12.5 },
        Order { item: "pen".into(), quantity: 10, price: 1.2 },
        Order { item: "desk".into(), quantity: 1, price: 210.0 },
    ];

    // The chain everyone ends up writing, and it stays readable.
    let large_total: f64 = orders
        .iter()
        .filter(|order| order.price > 10.0)
        .map(|order| order.price * f64::from(order.quantity))
        .sum();
    println!("{large_total:.2}");

    // Collecting into a different shape.
    let names: Vec<&str> = orders.iter().map(|o| o.item.as_str()).collect();
    println!("{names:?}");

    // A Result inside a collect turns a list of results into a result of a
    // list, which is exactly what you want when parsing input.
    let parsed: Result<Vec<i32>, _> = ["1", "2", "3"].iter().map(|s| s.parse::<i32>()).collect();
    println!("{parsed:?}");
    let failed: Result<Vec<i32>, _> = ["1", "x"].iter().map(|s| s.parse::<i32>()).collect();
    println!("{}", failed.is_err());

    // The adapters worth knowing by name.
    println!("{:?}", numbers.iter().take(3).collect::<Vec<_>>());
    println!("{:?}", numbers.iter().skip(7).collect::<Vec<_>>());
    println!("{:?}", numbers.iter().rev().take(2).collect::<Vec<_>>());
    println!("{:?}", numbers.chunks(4).collect::<Vec<_>>());
    println!("{:?}", numbers.windows(3).next());
    println!("{:?}", numbers.iter().zip("abc".chars()).collect::<Vec<_>>());
    println!("{:?}", numbers.iter().enumerate().find(|(_, n)| **n > 8));
    println!("{:?}", numbers.iter().position(|n| *n == 5));
    println!("{}", numbers.iter().any(|n| *n > 9));
    println!("{}", numbers.iter().all(|n| *n > 0));
    println!("{:?}", numbers.iter().max());
    println!("{:?}", numbers.iter().fold(0, |acc, n| acc + n));

    // flat_map and filter_map, for when each element gives zero or more.
    let lines = vec!["1 2", "3", "not a number"];
    let all: Vec<i32> = lines
        .iter()
        .flat_map(|line| line.split_whitespace())
        .filter_map(|word| word.parse().ok())
        .collect();
    println!("{all:?}");

    // Writing your own iterator is one method.
    let fib: Vec<u64> = Fibonacci::new().take(10).collect();
    println!("{fib:?}");
}

struct Fibonacci {
    current: u64,
    next: u64,
}

impl Fibonacci {
    fn new() -> Self {
        Fibonacci { current: 0, next: 1 }
    }
}

impl Iterator for Fibonacci {
    type Item = u64;

    fn next(&mut self) -> Option<u64> {
        let value = self.current;
        self.current = self.next;
        self.next = value + self.next;
        Some(value)
    }
}

The three ways to iterate

CallYieldsAfterwards
for x in &collection / .iter()&Tthe collection is untouched
for x in &mut collection / .iter_mut()&mut Telements can be changed in place
for x in collection / .into_iter()Tthe collection is consumed and gone

Getting this wrong is the most common early mistake: for word in words consumes the vector, so the next line that uses it fails to compile. The fix is a single ampersand, and the error message says so.

The adapters worth memorising

AdapterDoes
map, filterthe obvious two
filter_mapmap and drop the Nones in one pass
flat_map, flatteneach item gives zero or more
take, skip, take_while, skip_whilecutting the sequence
enumeratepairs of (index, item)
zipwalk two sequences together, stopping at the shorter
chainone after the other
revbackwards, for iterators that know how
peekablelook at the next item without taking it
windows, chunkson slices: overlapping and non overlapping groups
collectinto Vec, String, HashMap, or Result<Vec, E>
fold, reducewhen there is no adapter for what you want
any, all, find, positionsearching, all of which stop early
sum, product, min, max, countthe arithmetic ones
partition, unzipone pass, two outputs

Try it yourself

Given a slice of log lines like "2026-01-02 ERROR disk full", produce a HashMap<&str, usize> counting how many lines each level has, using one iterator chain and no mutable variable.

Hint: filter_map to pull out the level, then fold into a HashMap. Or collect into a Vec and use into_iter().fold(HashMap::new(), ...).

Show one solution
iterators.rs
//! Iterators. They are lazy, they compile down to the loop you would have
//! written by hand, and they are the main reason Rust code reads well.

#[derive(Debug, Clone)]
struct Order {
    item: String,
    quantity: u32,
    price: f64,
}

fn main() {
    let numbers: Vec<i32> = (1..=10).collect();

    // Nothing happens until something consumes the chain. map and filter
    // build a description; sum runs it, once, in one pass.
    let total: i32 = numbers.iter().filter(|n| *n % 2 == 0).map(|n| n * n).sum();
    println!("{total}");

    // Three ways to iterate, and the difference is ownership.
    //   iter()      yields &T,     the collection is untouched
    //   iter_mut()  yields &mut T, the elements can be changed
    //   into_iter() yields T,      the collection is consumed
    let mut words = vec![String::from("one"), String::from("two")];
    for word in words.iter() {
        print!("{word} ");
    }
    for word in words.iter_mut() {
        word.push('!');
    }
    println!("{words:?}");

    let orders = vec![
        Order { item: "book".into(), quantity: 2, price: 12.5 },
        Order { item: "pen".into(), quantity: 10, price: 1.2 },
        Order { item: "desk".into(), quantity: 1, price: 210.0 },
    ];

    // The chain everyone ends up writing, and it stays readable.
    let large_total: f64 = orders
        .iter()
        .filter(|order| order.price > 10.0)
        .map(|order| order.price * f64::from(order.quantity))
        .sum();
    println!("{large_total:.2}");

    // Collecting into a different shape.
    let names: Vec<&str> = orders.iter().map(|o| o.item.as_str()).collect();
    println!("{names:?}");

    // A Result inside a collect turns a list of results into a result of a
    // list, which is exactly what you want when parsing input.
    let parsed: Result<Vec<i32>, _> = ["1", "2", "3"].iter().map(|s| s.parse::<i32>()).collect();
    println!("{parsed:?}");
    let failed: Result<Vec<i32>, _> = ["1", "x"].iter().map(|s| s.parse::<i32>()).collect();
    println!("{}", failed.is_err());

    // The adapters worth knowing by name.
    println!("{:?}", numbers.iter().take(3).collect::<Vec<_>>());
    println!("{:?}", numbers.iter().skip(7).collect::<Vec<_>>());
    println!("{:?}", numbers.iter().rev().take(2).collect::<Vec<_>>());
    println!("{:?}", numbers.chunks(4).collect::<Vec<_>>());
    println!("{:?}", numbers.windows(3).next());
    println!("{:?}", numbers.iter().zip("abc".chars()).collect::<Vec<_>>());
    println!("{:?}", numbers.iter().enumerate().find(|(_, n)| **n > 8));
    println!("{:?}", numbers.iter().position(|n| *n == 5));
    println!("{}", numbers.iter().any(|n| *n > 9));
    println!("{}", numbers.iter().all(|n| *n > 0));
    println!("{:?}", numbers.iter().max());
    println!("{:?}", numbers.iter().fold(0, |acc, n| acc + n));

    // flat_map and filter_map, for when each element gives zero or more.
    let lines = vec!["1 2", "3", "not a number"];
    let all: Vec<i32> = lines
        .iter()
        .flat_map(|line| line.split_whitespace())
        .filter_map(|word| word.parse().ok())
        .collect();
    println!("{all:?}");

    // Writing your own iterator is one method.
    let fib: Vec<u64> = Fibonacci::new().take(10).collect();
    println!("{fib:?}");
}

struct Fibonacci {
    current: u64,
    next: u64,
}

impl Fibonacci {
    fn new() -> Self {
        Fibonacci { current: 0, next: 1 }
    }
}

impl Iterator for Fibonacci {
    type Item = u64;

    fn next(&mut self) -> Option<u64> {
        let value = self.current;
        self.current = self.next;
        self.next = value + self.next;
        Some(value)
    }
}
fn read_port(path: &str) -> Result<u16, ConfigError>

Result, Option and the question mark

No exceptions and no null. A function that can fail says so in its type, and the caller cannot ignore it by accident.

Option<T> is Some(T) or None. Result<T, E> is Ok(T) or Err(E). Both are ordinary enums, and both have to be unpacked before you can reach what is inside, so the case where there is nothing there cannot be skipped.

Unpacking them by hand every time would be unbearable, which is what ? is for: if this is an error, return it from the current function, converting it on the way; otherwise unwrap it and carry on.

errors.rs
//! Error handling. There are no exceptions. A function that can fail says so
//! in its return type, and the caller cannot ignore it by accident.

use std::fmt;
use std::fs;
use std::io;
use std::num::ParseIntError;

/// The standard shape for a library error: one enum with a variant per thing
/// that can go wrong, so callers can match on the cases they care about.
#[derive(Debug)]
pub enum ConfigError {
    Missing(String),
    Unreadable(io::Error),
    BadNumber { key: String, source: ParseIntError },
}

/// Display is what a user sees. Debug is what a developer sees.
impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ConfigError::Missing(key) => write!(f, "missing key: {key}"),
            ConfigError::Unreadable(err) => write!(f, "could not read config: {err}"),
            ConfigError::BadNumber { key, source } => {
                write!(f, "{key} is not a number: {source}")
            }
        }
    }
}

/// Implementing Error makes it work with ? and with everything that reports
/// errors. source() is what gives you the chain of causes.
impl std::error::Error for ConfigError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            ConfigError::Unreadable(err) => Some(err),
            ConfigError::BadNumber { source, .. } => Some(source),
            ConfigError::Missing(_) => None,
        }
    }
}

/// From is what makes ? convert automatically. With this, an io::Error
/// returned by ? inside a function returning ConfigError becomes a
/// ConfigError::Unreadable without a word being written at the call site.
impl From<io::Error> for ConfigError {
    fn from(err: io::Error) -> Self {
        ConfigError::Unreadable(err)
    }
}

/// The ? operator: if this is an Err, return it from the function now,
/// converting it with From on the way out. If it is Ok, unwrap it and carry
/// on. That one character replaces every nested try block.
pub fn read_port(path: &str) -> Result<u16, ConfigError> {
    let text = fs::read_to_string(path)?;

    let line = text
        .lines()
        .find(|line| line.starts_with("port="))
        .ok_or_else(|| ConfigError::Missing("port".to_string()))?;

    let raw = &line["port=".len()..];
    let port = raw
        .trim()
        .parse::<u16>()
        .map_err(|source| ConfigError::BadNumber { key: "port".into(), source })?;

    Ok(port)
}

/// Option has the same machinery. ? works on it too, in a function that
/// returns an Option.
pub fn initials(name: &str) -> Option<String> {
    let first = name.split_whitespace().next()?.chars().next()?;
    let last = name.split_whitespace().last()?.chars().next()?;
    Some(format!("{first}{last}"))
}

fn main() {
    match read_port("config.txt") {
        Ok(port) => println!("port {port}"),
        // Display for the message, and the source chain for the detail.
        Err(err) => {
            println!("failed: {err}");
            let mut cause = std::error::Error::source(&err);
            while let Some(inner) = cause {
                println!("  caused by: {inner}");
                cause = inner.source();
            }
        }
    }

    println!("{:?}", initials("ada lovelace"));

    // The combinators, for when a match would be four lines of noise.
    let raw = "42";
    println!("{}", raw.parse::<i32>().unwrap_or(0));
    println!("{}", raw.parse::<i32>().unwrap_or_default());
    println!("{}", raw.parse::<i32>().map(|n| n * 2).unwrap_or(-1));
    println!("{:?}", raw.parse::<i32>().ok());

    // unwrap and expect panic on failure. Fine in tests, examples and cases
    // that genuinely cannot happen. expect at least leaves a note saying why
    // you believed that.
    let definitely: i32 = "7".parse().expect("literal is a valid number");
    println!("{definitely}");
}
Python 3.12
class ConfigError(Exception):
    """Raised when the config cannot be used."""


def read_port(path: str) -> int:
    # Three things here can raise, and the signature mentions none of them.
    # Whether the caller handles any of it is a matter of memory.
    with open(path) as handle:
        for line in handle:
            if line.startswith("port="):
                return int(line.removeprefix("port=").strip())
    raise ConfigError("missing key: port")


def main() -> None:
    # Catching too much is the easy mistake, and it hides the bug you needed.
    try:
        print(read_port("config.txt"))
    except Exception as problem:  # noqa: BLE001
        print(f"failed: {problem}")

    # Catching too little is the other one. Nothing tells you that int() can
    # raise ValueError, so this handler is incomplete and looks finished.
    try:
        print(read_port("config.txt"))
    except FileNotFoundError:
        print("no config file")


if __name__ == "__main__":
    main()
JavaScript (Node 22)
export class ConfigError extends Error {}

export async function readPort(path) {
  const { readFile } = await import("node:fs/promises");
  // Throws on a missing file. Nothing in the signature says so, and the
  // thrown value is not even guaranteed to be an Error.
  const text = await readFile(path, "utf8");

  const line = text.split("\n").find((l) => l.startsWith("port="));
  if (!line) throw new ConfigError("missing key: port");

  const port = Number(line.slice("port=".length).trim());
  // Number never throws, so a typo becomes NaN and travels on quietly.
  if (Number.isNaN(port)) throw new ConfigError("port is not a number");
  return port;
}

try {
  console.log(await readPort("config.txt"));
} catch (problem) {
  // catch binds anything at all, so this could be a string or undefined.
  console.error(`failed: ${problem instanceof Error ? problem.message : problem}`);
}

In both, the signature is silent about failure. Catching too much hides the bug you needed, and catching too little looks finished.

Ignoring a Result is a warning, not silence

What the compiler says
$ cargo build
warning: unused `Result` that must be used
 --> src/main.rs:6:5
  |
6 |     fs::write("output.txt", "hello");
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: this `Result` may be an `Err` variant, which should be handled
  = note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
  |
6 |     let _ = fs::write("output.txt", "hello");
  |     +++++++
warning: `writer` (bin "writer") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.29s

Which error type

applications

anyhow

One error type that anything converts into, with a context("while reading config") method that builds a readable chain. Use it at the top of a binary, where nobody is going to match on the error, they are going to print it.

libraries

thiserror

A derive macro that writes the Display and Error implementations for your own enum. Use it in a library, where callers need to match on which thing went wrong. It generates exactly the code in the file above, and nothing at runtime.

by hand

Your own enum

Worth doing once, so the derive macros stop being magic. One variant per failure, Display for the message, Error for the source chain, and From for each error you want ? to convert automatically.

never

Box<dyn Error>

Works, requires no crates, and loses the ability to match on the cause. Fine in a prototype or an example. anyhow is the same idea with better ergonomics and a backtrace.

The combinators

CallDoes
unwrap()panic on failure. Tests, examples, and the genuinely impossible
expect("why")the same, with a note about what you believed
unwrap_or(default)a fallback value
unwrap_or_else(|e| ...)a fallback computed from the error
unwrap_or_default()whatever Default gives
map(|v| ...)change the success value
map_err(|e| ...)change the error, which is how you add context by hand
and_then(|v| ...)chain another fallible step
ok()Result to Option, throwing the error away
ok_or(err), ok_or_else(|| err)Option to Result
?return early on failure, converting with From
fn largest<T: PartialOrd + Copy>(items: &[T]) -> Option<T>

Traits and generics

Traits are shared behaviour without inheritance. Generics are how you write it once and pay nothing for it at runtime.

A trait is a set of methods a type can promise to have. Any type can implement any trait, including types from other crates implementing traits you defined, which is how the ecosystem composes without a class hierarchy.

traits.rs
//! Traits are shared behaviour. Generics are how you write code once for many
//! types without paying for it at runtime.

use std::fmt::{self, Display};

/// A trait is a set of methods a type can promise to have. Default methods
/// mean an implementer only has to write the parts that differ.
pub trait Summary {
    fn title(&self) -> String;

    fn summarise(&self) -> String {
        format!("{}, read more...", self.title())
    }
}

pub struct Article {
    pub headline: String,
    pub body: String,
}

pub struct Tweet {
    pub user: String,
    pub text: String,
}

impl Summary for Article {
    fn title(&self) -> String {
        self.headline.clone()
    }

    fn summarise(&self) -> String {
        format!("{}: {}", self.headline, &self.body[..self.body.len().min(40)])
    }
}

impl Summary for Tweet {
    fn title(&self) -> String {
        format!("@{}", self.user)
    }
}

/// Static dispatch. One copy of this function is compiled per type it is
/// called with, so the call is direct and can be inlined. This is what
/// "zero cost" means in practice.
pub fn announce(item: &impl Summary) {
    println!("new: {}", item.summarise());
}

/// Dynamic dispatch. One copy of the function, a pointer to the method table
/// at runtime, and the ability to hold a mixed collection. Slightly slower
/// and often worth it.
pub fn announce_all(items: &[Box<dyn Summary>]) {
    for item in items {
        println!("- {}", item.summarise());
    }
}

/// A generic function with a bound. T can be anything that can be compared
/// and copied, and the compiler checks the bound once, here, rather than at
/// every call site.
pub fn largest<T: PartialOrd + Copy>(items: &[T]) -> Option<T> {
    let mut iter = items.iter();
    let first = *iter.next()?;
    Some(iter.fold(first, |best, &item| if item > best { item } else { best }))
}

/// A where clause, for when the bounds get long enough to hurt the signature.
pub fn describe_all<T>(items: &[T]) -> String
where
    T: Display,
{
    items.iter().map(|i| i.to_string()).collect::<Vec<_>>().join(", ")
}

/// Implementing a standard trait is how a type joins the ecosystem. Display
/// gives you to_string and {} formatting for free.
pub struct Celsius(pub f64);

impl Display for Celsius {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{:.1}C", self.0)
    }
}

/// From gives you Into in the other direction automatically, which is why
/// you implement From and never Into.
impl From<f64> for Celsius {
    fn from(value: f64) -> Self {
        Celsius(value)
    }
}

fn main() {
    let article = Article {
        headline: String::from("Borrow checker accepts program"),
        body: String::from("Local developer reports mild disbelief at the news."),
    };
    let tweet = Tweet {
        user: String::from("rustlang"),
        text: String::from("1.85 is out"),
    };

    announce(&article);
    announce(&tweet);
    println!("{}", tweet.text);

    let mixed: Vec<Box<dyn Summary>> = vec![Box::new(article), Box::new(tweet)];
    announce_all(&mixed);

    println!("{:?}", largest(&[3, 7, 2]));
    println!("{:?}", largest(&[1.5, 0.5]));
    println!("{}", describe_all(&["a", "b"]));

    let temperature: Celsius = 21.5.into();
    println!("{temperature}");
}
Python 3.12
from typing import Protocol


class Summary(Protocol):
    """A structural interface. Nothing has to declare that it implements it."""

    def title(self) -> str: ...

    def summarise(self) -> str: ...


class Article:
    def __init__(self, headline: str, body: str) -> None:
        self.headline = headline
        self.body = body

    def title(self) -> str:
        return self.headline

    def summarise(self) -> str:
        return f"{self.headline}: {self.body[:40]}"


class Tweet:
    def __init__(self, user: str, text: str) -> None:
        self.user = user
        self.text = text

    def title(self) -> str:
        return f"@{self.user}"

    # Forgets summarise. Nothing complains until something calls it, and
    # a Protocol only checks anything if you run a type checker separately.
    # Rust's trait has default methods, so this case cannot arise.


def announce(item: Summary) -> None:
    print(f"new: {item.summarise()}")


def main() -> None:
    announce(Article("Borrow checker accepts program", "Local developer disbelieving."))
    # announce(Tweet("rustlang", "1.85 is out"))  # AttributeError at runtime


if __name__ == "__main__":
    main()
JavaScript (Node 22)
// Duck typing. There is no interface to declare and no compiler to check
// that a shape was implemented completely.
export class Article {
  constructor(headline, body) {
    this.headline = headline;
    this.body = body;
  }

  title() {
    return this.headline;
  }

  summarise() {
    return `${this.headline}: ${this.body.slice(0, 40)}`;
  }
}

export class Tweet {
  constructor(user, text) {
    this.user = user;
    this.text = text;
  }

  title() {
    return `@${this.user}`;
  }
  // No summarise. announce below fails at the call, with a TypeError that
  // names the method rather than the design mistake.
}

export function announce(item) {
  console.log(`new: ${item.summarise()}`);
}

announce(new Article("Borrow checker accepts program", "Local developer disbelieving."));
// announce(new Tweet("rustlang", "1.85 is out")); // TypeError: not a function

Duck typing gets you most of the way and tells you about the missing method at the call, at runtime, in production. A trait with a default method makes the incomplete implementation impossible.

Static and dynamic dispatch

impl Trait / genericdyn Trait
Resolvedat compile timeat runtime, through a vtable
Costnone, and calls can be inlinedone pointer indirection per call
Binary sizeone copy per concrete typeone copy total
Mixed collectionsno, every element is the same typeyes, which is usually why you reach for it
Written asfn f(x: &impl Summary)fn f(x: &dyn Summary)

Default to generics, and switch to dyn when you need a collection of different types or when compile times start to hurt. The vtable call is a few nanoseconds, not a design flaw, and a Vec<Box<dyn Handler>> is a perfectly ordinary thing to write.

The standard traits, and what implementing them buys

TraitGives you
Debug{:?} formatting. Derive it on nearly everything
Display{} formatting and .to_string(). Write it by hand
Clone, Copyexplicit and implicit duplication
PartialEq, Eq== and !=
PartialOrd, Ord<, sorting, and BinaryHeap
Hashbeing a HashMap key
DefaultType::default() and ..Default::default() in struct literals
From, TryFromconversions. Implement From and Into comes free
Iteratoryour type works with for and every adapter
Derefa wrapper that behaves like what it wraps
Dropcode that runs when the value goes out of scope
Send, Syncsafe to move to another thread, safe to share. Both automatic
  1. Associated types, when there is one right answer per implementer

    Iterator has type Item rather than a parameter, because a given iterator yields one type. That is why you write Iterator<Item = u64> rather than Iterator<u64>.

  2. Blanket implementations

    impl<T: Display> ToString for T in the standard library is why every Display type has to_string. You can do the same to give a whole category of types your behaviour at once.

  3. The orphan rule

    You can implement your trait for anyone's type, or anyone's trait for your type, but not someone else's trait for someone else's type. It is what stops two crates defining conflicting behaviour. The way around it is a newtype wrapper.

  4. async fn in traits

    Stable since 1.75. It works, with one caveat: the returned future is not automatically Send, so a trait used with tokio::spawn still often reaches for the async-trait crate or an explicit bound.

pub(crate) fn perimeter(&self) -> f64

Modules, crates and visibility

How a project is laid out, what pub actually means, and why there is no circular import problem.

A crate is the unit of compilation: one library or one binary. A module is a namespace inside it. Unlike Python, where a module is a file that runs, a Rust module is purely a naming and visibility boundary, resolved at compile time. There is no import order and no circular import problem, because nothing is executing.

modules.rs
//! Modules, visibility and the way a crate is laid out. In a real project
//! each module is usually its own file; this shows the whole shape at once.

/// A module. Everything in it is private unless marked pub, including to the
/// parent module.
pub mod geometry {
    /// pub here means visible outside the geometry module.
    #[derive(Debug, Clone, Copy)]
    pub struct Point {
        pub x: f64,
        pub y: f64,
    }

    impl Point {
        pub fn new(x: f64, y: f64) -> Self {
            Point { x, y }
        }

        /// Calls a private helper. Privacy is per module, not per type, so
        /// anything inside geometry can use distance_squared.
        pub fn distance_to(&self, other: &Point) -> f64 {
            distance_squared(self, other).sqrt()
        }
    }

    /// Private to this module. Nothing outside geometry can call it.
    fn distance_squared(a: &Point, b: &Point) -> f64 {
        (a.x - b.x).powi(2) + (a.y - b.y).powi(2)
    }

    /// A nested module. Paths get long, which is what use is for.
    pub mod shapes {
        use super::Point;

        #[derive(Debug)]
        pub struct Triangle {
            pub corners: [Point; 3],
        }

        impl Triangle {
            /// pub(crate) is visible anywhere in this crate and nowhere else.
            /// It is the right default for things other modules need but that
            /// are not part of the public API.
            pub(crate) fn perimeter(&self) -> f64 {
                let [a, b, c] = self.corners;
                a.distance_to(&b) + b.distance_to(&c) + c.distance_to(&a)
            }
        }
    }
}

/// use brings a path into scope. It does not import anything in the Python
/// sense: the code was already compiled in, this is only about names.
use geometry::shapes::Triangle;
use geometry::Point;

fn main() {
    let triangle = Triangle {
        corners: [Point::new(0.0, 0.0), Point::new(3.0, 0.0), Point::new(0.0, 4.0)],
    };
    println!("{:.2}", triangle.perimeter());

    // The full path works too, and is worth using for anything ambiguous.
    let origin = geometry::Point::new(0.0, 0.0);
    println!("{origin:?}");
}

How modules map to files

DeclarationLooks for
mod geometry;src/geometry.rs, or src/geometry/mod.rs
mod shapes; inside geometrysrc/geometry/shapes.rs
mod tests { }nothing, it is inline
use crate::geometry::Point;an absolute path from the crate root
use super::Point;the parent module
use self::helpers::x;this module
pub use geometry::Point;re-export, so callers see it at this level

mod declares that a module exists and pulls the file in. use only shortens a path. Writing use without the corresponding mod somewhere is the mistake everyone makes once, and the error says "unresolved import", which is the compiler asking where the module is declared.

Visibility

MarkerVisible to
(nothing)this module and its children. The default, and the right one
pubanyone who can reach the module it is in
pub(crate)anywhere in this crate, and nowhere outside it
pub(super)the parent module
pub(in path)a specific ancestor module

Workspaces

Cargo.toml
[package]
name = "taskserver"
version = "0.1.0"
edition = "2024"
rust-version = "1.85"

[dependencies]
# Ranges are caret by default: "1.0" means >=1.0.0 and <2.0.0.
anyhow = "1.0"
thiserror = "2.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
clap = { version = "4.5", features = ["derive"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time"] }
axum = "0.8"
rand = "0.9"

[dev-dependencies]
# Only compiled for tests and benchmarks, never in the shipped binary.
criterion = "0.5"
proptest = "1"

[profile.release]
# Slower to build, faster to run, and smaller.
opt-level = 3
lto = "thin"
codegen-units = 1
strip = "debuginfo"

[profile.dev]
# Dependencies optimised, your own code not, which keeps builds quick while
# leaving them fast enough to test against.
opt-level = 0

[profile.dev.package."*"]
opt-level = 2
match guess.cmp(&secret) { ... }

Build: guess the number

The canonical first Rust program, written the way you would write it now, with let-else instead of a nested match.

Small enough to hold in your head and large enough to touch mutability, loops, input, parsing, error handling and matching on an enum. Put it in src/main.rs, run cargo add rand, and cargo run.

src/main.rs
//! The canonical first Rust program, written the way you would write it now.

use rand::Rng;
use std::cmp::Ordering;
use std::io::{self, Write};

fn main() {
    // rand 0.9 renamed thread_rng to rng and gen_range to random_range.
    // Older tutorials will show the previous names.
    let secret = rand::rng().random_range(1..=100);
    println!("I picked a number between 1 and 100.");

    let mut attempts = 0;

    loop {
        print!("> ");
        // stdout is line buffered, so a prompt without a newline needs this.
        io::stdout().flush().expect("stdout should be writable");

        let mut input = String::new();
        if io::stdin().read_line(&mut input).unwrap_or(0) == 0 {
            println!("\nBye.");
            return;
        }

        // parse returns a Result, so bad input is a case rather than a crash.
        // let-else keeps the happy path unindented.
        let Ok(guess) = input.trim().parse::<u32>() else {
            println!("That is not a number.");
            continue;
        };

        attempts += 1;

        // cmp returns an enum with three variants, and the match must cover
        // all three. There is no way to forget the equal case.
        match guess.cmp(&secret) {
            Ordering::Less => println!("Higher."),
            Ordering::Greater => println!("Lower."),
            Ordering::Equal => {
                println!("Solved it in {attempts} guesses.");
                break;
            }
        }
    }
}
Python 3.12
import random


def main() -> None:
    secret = random.randint(1, 100)
    print("I picked a number between 1 and 100.")
    attempt = 1

    while True:
        entered = input("> ").strip()
        try:
            guess = int(entered)
        except ValueError:
            print("That is not a number.")
            continue

        if guess < secret:
            print("Higher.")
        elif guess > secret:
            print("Lower.")
        else:
            print(f"Solved it in {attempt} guesses.")
            return
        attempt += 1


if __name__ == "__main__":
    main()
JavaScript (Node 22)
import * as readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";

async function main() {
  const rl = readline.createInterface({ input, output });
  const secret = Math.floor(Math.random() * 100) + 1;
  console.log("I picked a number between 1 and 100.");

  let attempt = 1;
  for (;;) {
    const entered = (await rl.question("> ")).trim();
    const guess = Number(entered);

    if (!Number.isInteger(guess)) {
      console.log("That is not a number.");
      continue;
    }
    if (guess < secret) console.log("Higher.");
    else if (guess > secret) console.log("Lower.");
    else {
      console.log(`Solved it in ${attempt} guesses.`);
      break;
    }
    attempt += 1;
  }

  rl.close();
}

main();

Four things worth noticing

Bad input is a value. parse returns a Result, so a player typing "banana" is a case in the code rather than an exception thrown from somewhere else. let ... else handles it in two lines and keeps the rest of the loop unindented.

The comparison is an enum. cmp returns Ordering, which has exactly three variants, and the match must handle all three. There is no way to write this and forget the equal case.

mut is visible. attempts and input are declared mut and nothing else is. Anyone reading the function can see in one pass which things change.

The flush is not optional. Standard output is line buffered, so a prompt with no newline sits in the buffer until something flushes it. Every language has this; Rust makes you say it.

Try it yourself

Give the player a limited number of guesses and reveal the answer when they run out. Then keep every guess in a Vec<u32> and print them at the end, sorted.

Hint: A counter compared against a const MAX_ATTEMPTS, and a mut Vec outside the loop. Watch what the compiler says if you forget the mut.

Show one solution
guess.rs
//! The canonical first Rust program, written the way you would write it now.

use rand::Rng;
use std::cmp::Ordering;
use std::io::{self, Write};

fn main() {
    // rand 0.9 renamed thread_rng to rng and gen_range to random_range.
    // Older tutorials will show the previous names.
    let secret = rand::rng().random_range(1..=100);
    println!("I picked a number between 1 and 100.");

    let mut attempts = 0;

    loop {
        print!("> ");
        // stdout is line buffered, so a prompt without a newline needs this.
        io::stdout().flush().expect("stdout should be writable");

        let mut input = String::new();
        if io::stdin().read_line(&mut input).unwrap_or(0) == 0 {
            println!("\nBye.");
            return;
        }

        // parse returns a Result, so bad input is a case rather than a crash.
        // let-else keeps the happy path unindented.
        let Ok(guess) = input.trim().parse::<u32>() else {
            println!("That is not a number.");
            continue;
        };

        attempts += 1;

        // cmp returns an enum with three variants, and the match must cover
        // all three. There is no way to forget the equal case.
        match guess.cmp(&secret) {
            Ordering::Less => println!("Higher."),
            Ordering::Greater => println!("Lower."),
            Ordering::Equal => {
                println!("Solved it in {attempts} guesses.");
                break;
            }
        }
    }
}
enum State { Menu, Playing, Quit }

Build: an arithmetic drill with a state machine

A menu, settings and a score. The state machine is the part worth copying, because adding a state later breaks every match that has not handled it.

The same loop as the guessing game with something extra to carry: a settings struct, a running score, and a mode the program can be in. In Python that mode would be a string and a set of if branches. Here it is an enum, and the compiler audits every place that handles it.

src/main.rs
//! An arithmetic drill with a settings struct and a state enum. The state
//! machine is the part worth copying: adding a variant later breaks every
//! match that has not handled it, at compile time.

use rand::Rng;
use std::io::{self, Write};

#[derive(Debug, Clone, Copy, PartialEq)]
enum Operation {
    Add,
    Multiply,
}

impl Operation {
    fn symbol(self) -> char {
        match self {
            Operation::Add => '+',
            Operation::Multiply => '*',
        }
    }

    fn apply(self, a: i32, b: i32) -> i32 {
        match self {
            Operation::Add => a + b,
            Operation::Multiply => a * b,
        }
    }
}

#[derive(Debug)]
enum State {
    Menu,
    Playing,
    Quit,
}

#[derive(Debug)]
struct Settings {
    min: i32,
    max: i32,
    operation: Operation,
}

impl Default for Settings {
    fn default() -> Self {
        Settings { min: 1, max: 10, operation: Operation::Add }
    }
}

#[derive(Debug, Default)]
struct Score {
    correct: u32,
    asked: u32,
}

fn main() {
    let mut state = State::Menu;
    let mut settings = Settings::default();
    let mut score = Score::default();

    loop {
        match state {
            State::Menu => {
                println!("1. play   2. switch to {}   3. quit", other(settings.operation));
                match prompt("> ").as_str() {
                    "1" => state = State::Playing,
                    "2" => settings.operation = other(settings.operation),
                    "3" => state = State::Quit,
                    other => println!("no such option: {other}"),
                }
            }
            State::Playing => {
                let mut rng = rand::rng();
                let a = rng.random_range(settings.min..=settings.max);
                let b = rng.random_range(settings.min..=settings.max);
                let answer = settings.operation.apply(a, b);

                let input = prompt(&format!("{a} {} {b} = ", settings.operation.symbol()));
                if input == "quit" {
                    state = State::Menu;
                    continue;
                }

                score.asked += 1;
                match input.parse::<i32>() {
                    Ok(value) if value == answer => {
                        score.correct += 1;
                        println!("Correct.");
                    }
                    Ok(_) => println!("No, it was {answer}."),
                    Err(_) => println!("That is not a number. It was {answer}."),
                }
            }
            State::Quit => {
                println!("{} correct out of {}", score.correct, score.asked);
                return;
            }
        }
    }
}

fn other(operation: Operation) -> Operation {
    match operation {
        Operation::Add => Operation::Multiply,
        Operation::Multiply => Operation::Add,
    }
}

fn prompt(text: &str) -> String {
    print!("{text}");
    io::stdout().flush().ok();
    let mut input = String::new();
    io::stdin().read_line(&mut input).ok();
    input.trim().to_string()
}
Python 3.12
import random
from dataclasses import dataclass


@dataclass
class Score:
    correct: int = 0
    asked: int = 0


def main() -> None:
    print("Addition drill. Type quit to stop.")
    score = Score()

    while True:
        a, b = random.randint(1, 10), random.randint(1, 10)
        entered = input(f"What is {a} + {b}? ").strip()
        if entered == "quit":
            break

        answer = a + b
        is_right = entered.lstrip("-").isdigit() and int(entered) == answer
        print("Correct." if is_right else f"No, it was {answer}.")

        # The score object is edited in place, so anything holding a
        # reference to it sees the change.
        score.asked += 1
        score.correct += int(is_right)

    print(f"{score.correct} correct out of {score.asked}")


if __name__ == "__main__":
    main()
JavaScript (Node 22)
import * as readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";

async function main() {
  const rl = readline.createInterface({ input, output });
  console.log("Addition drill. Type quit to stop.");

  const score = { correct: 0, asked: 0 };

  for (;;) {
    const a = Math.floor(Math.random() * 10) + 1;
    const b = Math.floor(Math.random() * 10) + 1;
    const entered = (await rl.question(`What is ${a} + ${b}? `)).trim();
    if (entered === "quit") break;

    const answer = a + b;
    const isRight = Number(entered) === answer;
    console.log(isRight ? "Correct." : `No, it was ${answer}.`);

    score.asked += 1;
    score.correct += isRight ? 1 : 0;
  }

  console.log(`${score.correct} correct out of ${score.asked}`);
  rl.close();
}

main();

Both keep the state in a string. Adding a fourth mode means finding every comparison by hand, and a typo in one of them is a bug that only shows up on that path.

Why the enum is worth the extra lines

Add a State::Paused variant and the match in main stops compiling until it is handled. Add a Operation::Subtract and both symbol and apply stop compiling. In each case the compiler produces a list of exactly the places that need attention, which is the difference between a refactor and a search.

This is the single most useful property of Rust in a codebase that several people work on, and it is the thing that is hardest to appreciate from a small example.

pub fn tally(text: &str) -> HashMap<String, usize>

Build: counting words, with tests

A thin layer that talks to the world and pure functions underneath, which is what makes the tests need no fixtures.

src/main.rs
//! Counting words in a file. A thin layer that talks to the world, and pure
//! functions underneath that the tests can call directly.

use std::collections::HashMap;
use std::fs;
use std::process::ExitCode;

/// Splitting is pure, so it can be tested on a literal.
pub fn words(text: &str) -> impl Iterator<Item = String> + '_ {
    text.split(|c: char| !c.is_alphanumeric() && c != '\'')
        .filter(|word| !word.is_empty())
        .map(|word| word.to_lowercase())
}

pub fn tally(text: &str) -> HashMap<String, usize> {
    let mut counts = HashMap::new();
    for word in words(text) {
        // entry gives a mutable reference to the value, inserting the
        // default first if the key was absent. One lookup, not two.
        *counts.entry(word).or_default() += 1;
    }
    counts
}

/// Sorted by count descending, then alphabetically so the output is stable
/// and therefore testable.
pub fn top(text: &str, limit: usize) -> Vec<(String, usize)> {
    let mut ranked: Vec<(String, usize)> = tally(text).into_iter().collect();
    ranked.sort_by(|(word_a, count_a), (word_b, count_b)| {
        count_b.cmp(count_a).then(word_a.cmp(word_b))
    });
    ranked.truncate(limit);
    ranked
}

/// Returning ExitCode rather than calling process::exit means destructors
/// still run on the way out.
fn main() -> ExitCode {
    let Some(path) = std::env::args().nth(1) else {
        eprintln!("usage: word_count FILE");
        return ExitCode::FAILURE;
    };

    let text = match fs::read_to_string(&path) {
        Ok(text) => text,
        Err(err) => {
            eprintln!("could not read {path}: {err}");
            return ExitCode::FAILURE;
        }
    };

    for (word, count) in top(&text, 10) {
        println!("{word:<16}{count}");
    }

    ExitCode::SUCCESS
}

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

    #[test]
    fn splits_on_punctuation() {
        let found: Vec<String> = words("One, two!").collect();
        assert_eq!(found, vec!["one", "two"]);
    }

    #[test]
    fn counts_repeats() {
        let counts = tally("a b a");
        assert_eq!(counts.get("a"), Some(&2));
        assert_eq!(counts.get("b"), Some(&1));
        assert_eq!(counts.get("z"), None);
    }

    #[test]
    fn ranks_by_count_then_alphabetically() {
        assert_eq!(
            top("b a a c c c", 2),
            vec![("c".to_string(), 3), ("a".to_string(), 2)]
        );
    }

    #[test]
    fn empty_input_has_no_words() {
        assert!(tally("").is_empty());
    }
}
Python 3.12
import sys
from collections import Counter


def tokens(text: str) -> list[str]:
    return "".join(c.lower() if c.isalpha() else " " for c in text).split()


def tally(text: str) -> Counter[str]:
    return Counter(tokens(text))


def main() -> None:
    if len(sys.argv) != 2:
        print("usage: wordcount FILE")
        return

    with open(sys.argv[1], encoding="utf-8") as handle:
        contents = handle.read()

    for word, count in tally(contents).most_common(10):
        print(f"{word:<16}{count}")


if __name__ == "__main__":
    main()
JavaScript (Node 22)
import { readFile } from "node:fs/promises";
import { argv } from "node:process";

export const tokens = (text) =>
  [...text.toLowerCase()]
    .map((c) => (/\p{L}/u.test(c) ? c : " "))
    .join("")
    .split(/\s+/)
    .filter(Boolean);

export function tally(text) {
  const counts = new Map();
  for (const word of tokens(text)) {
    counts.set(word, (counts.get(word) ?? 0) + 1);
  }
  return counts;
}

async function main() {
  const path = argv[2];
  if (!path) {
    console.log("usage: wordcount FILE");
    return;
  }

  const contents = await readFile(path, "utf8");
  const ranked = [...tally(contents)].sort((a, b) => b[1] - a[1]).slice(0, 10);

  for (const [word, count] of ranked) {
    console.log(word.padEnd(16) + count);
  }
}

main();

Python's Counter does more of the work. The Rust version spells out the fold, which is a fair trade for a tally you can call on a literal in a test.

cargo test
$ cargo test
   Compiling wordcount v0.1.0 (/home/you/wordcount)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running unittests src/lib.rs

running 5 tests
test tests::counts_repeats ... ok
test tests::empty_input_has_no_words ... ok
test tests::gives_the_extra_cents_to_the_first_people ... ok
test tests::ranks_by_count_then_alphabetically ... ok
test tests::splits_on_punctuation ... FAILED

failures:

---- tests::splits_on_punctuation stdout ----
thread 'tests::splits_on_punctuation' panicked at src/lib.rs:63:9:
assertion `left == right` failed
  left: ["one", "two!"]
 right: ["one", "two"]
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

failures:
    tests::splits_on_punctuation

test result: FAILED. 4 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests wordcount

running 1 test
test src/lib.rs - split_bill (line 8) ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

The shape to copy

words, tally and top take a &str and return a value. They touch no files, read no arguments and print nothing, so the tests at the bottom call them directly on string literals. No temporary directory, no fixture, no mock.

main is the only function that knows a filesystem exists, and it is four lines of reading arguments and one of printing. Pushing IO to the edges is good advice in any language; Rust makes it easier to stick to, because a function that does no IO cannot accidentally start doing some.

Try it yourself

Add a stop word list, a --limit argument, and a mode that reads from standard input when no path is given so the tool works in a pipe. Then check whether the tests still need changing, and notice that they do not.

Hint: For stdin, std::io::read_to_string(std::io::stdin()). The pure functions never knew where the text came from, which is the point.

Show one solution
word_count.rs
//! Counting words in a file. A thin layer that talks to the world, and pure
//! functions underneath that the tests can call directly.

use std::collections::HashMap;
use std::fs;
use std::process::ExitCode;

/// Splitting is pure, so it can be tested on a literal.
pub fn words(text: &str) -> impl Iterator<Item = String> + '_ {
    text.split(|c: char| !c.is_alphanumeric() && c != '\'')
        .filter(|word| !word.is_empty())
        .map(|word| word.to_lowercase())
}

pub fn tally(text: &str) -> HashMap<String, usize> {
    let mut counts = HashMap::new();
    for word in words(text) {
        // entry gives a mutable reference to the value, inserting the
        // default first if the key was absent. One lookup, not two.
        *counts.entry(word).or_default() += 1;
    }
    counts
}

/// Sorted by count descending, then alphabetically so the output is stable
/// and therefore testable.
pub fn top(text: &str, limit: usize) -> Vec<(String, usize)> {
    let mut ranked: Vec<(String, usize)> = tally(text).into_iter().collect();
    ranked.sort_by(|(word_a, count_a), (word_b, count_b)| {
        count_b.cmp(count_a).then(word_a.cmp(word_b))
    });
    ranked.truncate(limit);
    ranked
}

/// Returning ExitCode rather than calling process::exit means destructors
/// still run on the way out.
fn main() -> ExitCode {
    let Some(path) = std::env::args().nth(1) else {
        eprintln!("usage: word_count FILE");
        return ExitCode::FAILURE;
    };

    let text = match fs::read_to_string(&path) {
        Ok(text) => text,
        Err(err) => {
            eprintln!("could not read {path}: {err}");
            return ExitCode::FAILURE;
        }
    };

    for (word, count) in top(&text, 10) {
        println!("{word:<16}{count}");
    }

    ExitCode::SUCCESS
}

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

    #[test]
    fn splits_on_punctuation() {
        let found: Vec<String> = words("One, two!").collect();
        assert_eq!(found, vec!["one", "two"]);
    }

    #[test]
    fn counts_repeats() {
        let counts = tally("a b a");
        assert_eq!(counts.get("a"), Some(&2));
        assert_eq!(counts.get("b"), Some(&1));
        assert_eq!(counts.get("z"), None);
    }

    #[test]
    fn ranks_by_count_then_alphabetically() {
        assert_eq!(
            top("b a a c c c", 2),
            vec![("c".to_string(), 3), ("a".to_string(), 2)]
        );
    }

    #[test]
    fn empty_input_has_no_words() {
        assert!(tally("").is_empty());
    }
}
fn sort_folder(root: &Path, dry_run: bool) -> io::Result<usize>

Build: a downloads folder sorter

Filesystem work, Option chaining and the question mark doing all the error handling, in a tool that refuses to move anything until you ask twice.

src/main.rs
//! Sorting a downloads folder by file type. Filesystem work, Option chaining
//! and the ? operator doing the error handling.

use std::fs;
use std::io;
use std::path::{Path, PathBuf};

/// A plain function returning io::Result, so every ? below can bubble an
/// error straight out to the caller.
pub fn sort_folder(root: &Path, dry_run: bool) -> io::Result<usize> {
    if !root.is_dir() {
        return Err(io::Error::new(
            io::ErrorKind::NotFound,
            format!("{} is not a directory", root.display()),
        ));
    }

    let mut moved = 0;

    for entry in fs::read_dir(root)? {
        // read_dir yields Results, because a single entry can fail on its own.
        let entry = entry?;
        let path = entry.path();

        if !path.is_file() {
            continue;
        }

        // Option chaining: extension() gives Option<&OsStr>, and to_str gives
        // Option<&str> because not every filename is valid UTF-8. and_then
        // threads the two together without a nested match.
        let Some(extension) = path.extension().and_then(|ext| ext.to_str()) else {
            continue;
        };

        let folder = category(&extension.to_lowercase());
        let target = root.join(folder);

        if dry_run {
            println!("would move {} to {folder}/", path.display());
        } else {
            fs::create_dir_all(&target)?;
            fs::rename(&path, unique_destination(&target, &path))?;
        }
        moved += 1;
    }

    Ok(moved)
}

fn category(extension: &str) -> &'static str {
    match extension {
        "pdf" | "doc" | "docx" | "txt" | "md" => "documents",
        "jpg" | "jpeg" | "png" | "gif" | "mp4" | "mov" => "media",
        "zip" | "tar" | "gz" | "7z" => "archives",
        "csv" | "json" | "parquet" => "data",
        _ => "other",
    }
}

/// Never silently overwrite something. Add a counter until the name is free.
fn unique_destination(target: &Path, source: &Path) -> PathBuf {
    let name = source.file_name().unwrap_or_default();
    let mut candidate = target.join(name);
    let stem = source.file_stem().and_then(|s| s.to_str()).unwrap_or("file");
    let extension = source.extension().and_then(|e| e.to_str()).unwrap_or("");

    let mut counter = 1;
    while candidate.exists() {
        candidate = target.join(format!("{stem}-{counter}.{extension}"));
        counter += 1;
    }
    candidate
}

fn main() -> io::Result<()> {
    let root = std::env::args().nth(1).unwrap_or_else(|| ".".to_string());
    // Default to a dry run, because a tool that moves files should have to be
    // asked twice.
    let apply = std::env::args().any(|arg| arg == "--apply");

    let moved = sort_folder(Path::new(&root), !apply)?;
    println!("{moved} file(s) {}", if apply { "moved" } else { "would move" });
    Ok(())
}

Three patterns worth taking away

  1. ? on every fallible call

    Six operations here can fail and there is not a single nested match. Each ? returns the error to the caller if there is one. The function reads as the happy path, which is what error handling should look like.

  2. and_then for chained Options

    path.extension() gives Option<&OsStr> and to_str gives Option<&str>, because not every filename on every platform is valid UTF-8. and_then threads them together, and let ... else handles the None by skipping the file.

  3. Never overwrite silently

    unique_destination adds a counter until the name is free. A tool that moves files should not be able to destroy one, and this is four lines.

Try it yourself

Add recursion into subdirectories, a --by-date mode that sorts into year and month folders instead, and an undo log that records every move so the whole thing can be reversed.

Hint: For the log, write one line of JSON per move with serde. For recursion, the walkdir crate exists and handles symlink loops, which a hand rolled version will not.

Show one solution
sorter.rs
//! Sorting a downloads folder by file type. Filesystem work, Option chaining
//! and the ? operator doing the error handling.

use std::fs;
use std::io;
use std::path::{Path, PathBuf};

/// A plain function returning io::Result, so every ? below can bubble an
/// error straight out to the caller.
pub fn sort_folder(root: &Path, dry_run: bool) -> io::Result<usize> {
    if !root.is_dir() {
        return Err(io::Error::new(
            io::ErrorKind::NotFound,
            format!("{} is not a directory", root.display()),
        ));
    }

    let mut moved = 0;

    for entry in fs::read_dir(root)? {
        // read_dir yields Results, because a single entry can fail on its own.
        let entry = entry?;
        let path = entry.path();

        if !path.is_file() {
            continue;
        }

        // Option chaining: extension() gives Option<&OsStr>, and to_str gives
        // Option<&str> because not every filename is valid UTF-8. and_then
        // threads the two together without a nested match.
        let Some(extension) = path.extension().and_then(|ext| ext.to_str()) else {
            continue;
        };

        let folder = category(&extension.to_lowercase());
        let target = root.join(folder);

        if dry_run {
            println!("would move {} to {folder}/", path.display());
        } else {
            fs::create_dir_all(&target)?;
            fs::rename(&path, unique_destination(&target, &path))?;
        }
        moved += 1;
    }

    Ok(moved)
}

fn category(extension: &str) -> &'static str {
    match extension {
        "pdf" | "doc" | "docx" | "txt" | "md" => "documents",
        "jpg" | "jpeg" | "png" | "gif" | "mp4" | "mov" => "media",
        "zip" | "tar" | "gz" | "7z" => "archives",
        "csv" | "json" | "parquet" => "data",
        _ => "other",
    }
}

/// Never silently overwrite something. Add a counter until the name is free.
fn unique_destination(target: &Path, source: &Path) -> PathBuf {
    let name = source.file_name().unwrap_or_default();
    let mut candidate = target.join(name);
    let stem = source.file_stem().and_then(|s| s.to_str()).unwrap_or("file");
    let extension = source.extension().and_then(|e| e.to_str()).unwrap_or("");

    let mut counter = 1;
    while candidate.exists() {
        candidate = target.join(format!("{stem}-{counter}.{extension}"));
        counter += 1;
    }
    candidate
}

fn main() -> io::Result<()> {
    let root = std::env::args().nth(1).unwrap_or_else(|| ".".to_string());
    // Default to a dry run, because a tool that moves files should have to be
    // asked twice.
    let apply = std::env::args().any(|arg| arg == "--apply");

    let moved = sort_folder(Path::new(&root), !apply)?;
    println!("{moved} file(s) {}", if apply { "moved" } else { "would move" });
    Ok(())
}
#[derive(Parser, Serialize, Deserialize)]

Build: a JSON command line tool

clap builds the argument parser from a struct and serde builds the JSON parser from another. Both are derive macros, and both generate code at compile time.

Two crates cover most command line work, and both are worth knowing properly because they show what derive macros are actually for. You describe the shape you want as a type, and the macro writes the parsing code that produces it.

src/main.rs
//! A small CLI that reads JSON and prints a report. serde does the parsing at
//! compile time, clap builds the argument parser from a struct.

use clap::Parser;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;

/// Doc comments become the help text, which is why they are worth writing.
#[derive(Parser, Debug)]
#[command(version, about = "Summarise a users.json file")]
struct Args {
    /// Path to the JSON file
    path: PathBuf,

    /// Only show users who are active
    #[arg(short, long)]
    active_only: bool,

    /// Maximum number of rows to print
    #[arg(short, long, default_value_t = 20)]
    limit: usize,
}

#[derive(Debug, Serialize, Deserialize)]
struct User {
    id: u32,
    name: String,
    #[serde(default)]
    active: bool,
    /// A field that may be absent in the file maps to Option.
    email: Option<String>,
}

/// anyhow::Result is the right tool at the top of a binary: any error type
/// goes in, and the context lines make the report readable.
fn main() -> anyhow::Result<()> {
    let args = Args::parse();

    let text = fs::read_to_string(&args.path)?;
    let mut users: Vec<User> = serde_json::from_str(&text)?;

    if args.active_only {
        users.retain(|user| user.active);
    }
    users.sort_by(|a, b| a.name.cmp(&b.name));

    for user in users.iter().take(args.limit) {
        let email = user.email.as_deref().unwrap_or("no email");
        println!("{:<4} {:<20} {email}", user.id, user.name);
    }

    println!("{} user(s)", users.len());
    Ok(())
}
Cargo.toml
[package]
name = "taskserver"
version = "0.1.0"
edition = "2024"
rust-version = "1.85"

[dependencies]
# Ranges are caret by default: "1.0" means >=1.0.0 and <2.0.0.
anyhow = "1.0"
thiserror = "2.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
clap = { version = "4.5", features = ["derive"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time"] }
axum = "0.8"
rand = "0.9"

[dev-dependencies]
# Only compiled for tests and benchmarks, never in the shipped binary.
criterion = "0.5"
proptest = "1"

[profile.release]
# Slower to build, faster to run, and smaller.
opt-level = 3
lto = "thin"
codegen-units = 1
strip = "debuginfo"

[profile.dev]
# Dependencies optimised, your own code not, which keeps builds quick while
# leaving them fast enough to test against.
opt-level = 0

[profile.dev.package."*"]
opt-level = 2

serde

#[derive(Deserialize)] generates a parser specialised to this exact struct at compile time. There is no reflection and no runtime schema, which is why serde_json regularly beats the JSON parsers built into other languages by a wide margin.

The type is the schema. A field of type u32 means the JSON must have a number that fits, and a field of Option<String> means the key may be absent. A malformed document is an error at the parse, not an undefined discovered four functions later.

AttributeDoes
#[serde(rename = "userId")]map a field to a different JSON name
#[serde(rename_all = "camelCase")]do it for the whole struct
#[serde(default)]use Default when the key is missing
#[serde(skip)]never read or write this field
#[serde(skip_serializing_if = "Option::is_none")]leave nulls out of the output
#[serde(tag = "type")]an internally tagged enum, which is how most APIs shape unions
#[serde(flatten)]inline a nested struct's fields into the parent

clap

The struct is the interface. Field names become flags, doc comments become help text, types decide the parsing, and Option<T> makes an argument optional. You get --help, --version, error messages that name the bad argument, and shell completions, without writing any of it.

Rc<RefCell<Vec<i32>>>

Box, Rc and RefCell

Each one buys back something ownership took away, and each has a cost you should be able to name before you reach for it.

Single ownership covers most code and genuinely does not cover all of it. A tree node needs to contain itself, a cache needs several owners, an observer needs to mutate through a shared handle. The standard library has one type for each of those, and knowing which problem each solves stops them being interchangeable magic.

smart_pointers.rs
//! Box, Rc and RefCell. Each one buys back something ownership took away,
//! and each one has a cost you should be able to name.

use std::cell::RefCell;
use std::rc::Rc;

/// Box puts a value on the heap. The main reason to need it: a type that
/// contains itself has no known size, and a pointer does.
#[derive(Debug)]
enum Tree {
    Leaf(i32),
    Node(Box<Tree>, Box<Tree>),
}

impl Tree {
    fn sum(&self) -> i32 {
        match self {
            Tree::Leaf(value) => *value,
            Tree::Node(left, right) => left.sum() + right.sum(),
        }
    }
}

/// Rc is a reference counted pointer for a single thread. It allows several
/// owners of one value, and the value is freed when the last one goes.
#[derive(Debug)]
struct Document {
    title: String,
}

/// RefCell moves the borrow check from compile time to runtime. You get
/// mutation through a shared reference, and a panic instead of an error if
/// two borrows conflict. Together, Rc<RefCell<T>> is the closest Rust gets
/// to an ordinary mutable object graph.
#[derive(Debug)]
struct Counter {
    hits: RefCell<u32>,
}

impl Counter {
    fn record(&self) {
        *self.hits.borrow_mut() += 1;
    }
}

fn main() {
    let tree = Tree::Node(
        Box::new(Tree::Leaf(1)),
        Box::new(Tree::Node(Box::new(Tree::Leaf(2)), Box::new(Tree::Leaf(3)))),
    );
    println!("tree sums to {}", tree.sum());

    let document = Rc::new(Document { title: String::from("notes") });
    let first = Rc::clone(&document);
    let second = Rc::clone(&document);
    println!(
        "{} has {} owners",
        first.title,
        Rc::strong_count(&document)
    );
    drop(second);
    println!("after one is dropped: {}", Rc::strong_count(&document));

    // Mutation through a shared reference, checked at runtime.
    let counter = Counter { hits: RefCell::new(0) };
    counter.record();
    counter.record();
    println!("{} hits", counter.hits.borrow());

    // The combination. Several owners, all able to change the value.
    let shared = Rc::new(RefCell::new(vec![1, 2, 3]));
    let other_handle = Rc::clone(&shared);
    other_handle.borrow_mut().push(4);
    println!("{:?}", shared.borrow());

    // Box<dyn Trait> for a value whose type is only known at runtime.
    let steps: Vec<Box<dyn Fn(i32) -> i32>> =
        vec![Box::new(|x| x + 1), Box::new(|x| x * 2)];
    let result = steps.iter().fold(5, |value, step| step(value));
    println!("{result}");
}

Box<T>

One owner, on the heap

A pointer with the same ownership rules as anything else. You need it for a recursive type, because a type containing itself has no known size, and for a trait object, because dyn Trait has no size either. Costs one allocation and nothing else.

Rc<T>

Several owners, one thread

A reference count that goes up on clone and down on drop; the value goes when it hits zero. Not thread safe, which is enforced: Rc is not Send, so trying to move one to another thread does not compile. Read only unless combined with a cell.

RefCell<T>

Borrow checking at runtime

Mutation through a shared reference, with the borrow rule checked when you call borrow or borrow_mut. Break it and you get a panic rather than a compile error, which is the whole trade.

Arc<T>

Several owners, several threads

Rc with an atomic count. Slightly slower and safe to share. Pair it with Mutex or RwLock when the shared value needs to change, which is the subject of the next chapter.

Which one

You needUse
A recursive type, or a trait objectBox<T>
Several owners on one threadRc<T>
Several owners, and mutation, one threadRc<RefCell<T>>
Several owners across threads, read onlyArc<T>
Several owners across threads, with mutationArc<Mutex<T>>
One value mutated through &self, Copy typeCell<T>, which has no runtime check at all
Arc<Mutex<i32>>

Threads, channels and shared state

The rule that prevents dangling pointers turns out to prevent data races too. This is the part where the earlier work pays off.

Rust's claim here is specific and worth stating precisely: a data race, meaning two threads accessing the same memory with at least one writing and no synchronisation, cannot happen in safe Rust. Not unlikely, not caught by a linter. It does not compile.

It falls out of two automatic traits. Send means a value can be moved to another thread. Sync means &T can be shared between threads. The compiler works out which types have them, and thread::spawn requires them.

concurrency.rs
//! Threads, channels and shared state. The type system is what makes this
//! safe: a data race is a compile error rather than a bug you find in
//! production six months later.

use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;

fn main() {
    threads();
    channels();
    shared_state();
    scoped();
}

/// spawn takes a closure that must be able to move to another thread, which
/// is what the Send bound means. move gives the closure ownership of what it
/// captured, because the thread may outlive this function.
fn threads() {
    let handles: Vec<_> = (0..4)
        .map(|id| {
            thread::spawn(move || {
                thread::sleep(Duration::from_millis(10 * id));
                format!("worker {id} finished")
            })
        })
        .collect();

    for handle in handles {
        // join waits and gives back what the closure returned. If the thread
        // panicked, this is an Err rather than a silent loss.
        match handle.join() {
            Ok(message) => println!("{message}"),
            Err(_) => println!("a worker panicked"),
        }
    }
}

/// A channel. The sender can be cloned for many producers; the receiver
/// cannot, which is the type system spelling out that this is many to one.
fn channels() {
    let (tx, rx) = mpsc::channel();

    for id in 0..3 {
        let tx = tx.clone();
        thread::spawn(move || {
            for n in 0..3 {
                tx.send((id, n)).expect("receiver should still be alive");
            }
        });
    }
    // The original sender has to go, or the loop below never ends.
    drop(tx);

    let mut total = 0;
    for (id, n) in rx {
        total += n;
        print!("({id},{n}) ");
    }
    println!("\nreceived, total {total}");
}

/// Shared mutable state. Arc for many owners across threads, Mutex for
/// exclusive access. Forgetting the lock is impossible, because the data is
/// inside it and lock() is the only way to reach it.
fn shared_state() {
    let counter = Arc::new(Mutex::new(0));

    let handles: Vec<_> = (0..8)
        .map(|_| {
            let counter = Arc::clone(&counter);
            thread::spawn(move || {
                for _ in 0..1000 {
                    let mut value = counter.lock().expect("lock should not be poisoned");
                    *value += 1;
                }
            })
        })
        .collect();

    for handle in handles {
        handle.join().expect("worker should not panic");
    }

    println!("counted to {}", counter.lock().expect("lock is fine"));
}

/// Scoped threads, stable since 1.63. They are guaranteed to finish before
/// the scope ends, so they can borrow local data without Arc at all.
fn scoped() {
    let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8];

    let (left, right) = numbers.split_at(4);
    let mut totals = (0, 0);

    thread::scope(|scope| {
        let first = scope.spawn(|| left.iter().sum::<i32>());
        let second = scope.spawn(|| right.iter().sum::<i32>());
        totals = (
            first.join().expect("no panic"),
            second.join().expect("no panic"),
        );
    });

    println!("{totals:?} adds to {}", totals.0 + totals.1);
}

What it looks like when you get it wrong

What the compiler says
$ cargo check
error[E0277]: `Rc<i32>` cannot be sent between threads safely
   --> src/main.rs:8:19
    |
8   |       thread::spawn(move || {
    |       ------------- ^------
    |       |             |
    |  _____|_____________within this `{closure@src/main.rs:8:19}`
    | |     |
    | |     required by a bound introduced by this call
9   | |         println!("{}", shared);
10  | |     });
    | |_____^ `Rc<i32>` cannot be sent between threads safely
    |
    = help: within `{closure@src/main.rs:8:19}`, the trait `Send` is not implemented for `Rc<i32>`
note: required because it appears within the type `{closure@src/main.rs:8:19}`
    = note: use `std::sync::Arc` instead of `std::rc::Rc`
For more information about this error, try `rustc --explain E0277`.

Rc uses a non atomic counter, so two threads cloning one could lose an increment and free the value early. The compiler knows this because Rc is not Send, and the note at the bottom tells you to use Arc. In Python or JavaScript the equivalent mistake is not a compile error, it is a heisenbug.

Python 3.12
import threading
from concurrent.futures import ThreadPoolExecutor


class Counter:
    def __init__(self) -> None:
        self.count = 0
        # Nothing in the language ties this lock to the field it protects.
        # Taking it is a convention enforced by code review.
        self._lock = threading.Lock()

    def add(self, amount: int) -> None:
        with self._lock:
            self.count += amount


def main() -> None:
    counter = Counter()
    with ThreadPoolExecutor(max_workers=8) as pool:
        for _ in range(8):
            pool.submit(lambda: [counter.add(1) for _ in range(1000)])
    print(counter.count)

    # Forgetting the lock is a data race that usually works. It will pass
    # every test you write and lose an update in production under load.
    unsafe = 0

    def bump() -> None:
        nonlocal unsafe
        for _ in range(100000):
            unsafe += 1

    threads = [threading.Thread(target=bump) for _ in range(4)]
    for thread in threads:
        thread.start()
    for thread in threads:
        thread.join()
    print(unsafe, "and it should be 400000")

    # The GIL means threads do not run Python bytecode in parallel anyway,
    # so CPU bound work needs multiprocessing and its copying costs. The
    # free threaded build in 3.13 changes this and brings the data races
    # with it.


if __name__ == "__main__":
    main()
JavaScript (Node 22)
// One thread, so there is no lock to forget and no parallelism either. A
// long loop in one handler blocks every other handler, every timer and the
// health check.
let count = 0;
for (let i = 0; i < 8000; i++) count += 1;
console.log(count);

// Real parallelism means worker_threads, and workers cannot share ordinary
// objects. State is copied through postMessage, or squeezed into a
// SharedArrayBuffer with manual Atomics, which is the lock problem again
// with fewer tools and no compiler checking any of it.
import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";

if (isMainThread) {
  const shared = new SharedArrayBuffer(4);
  const view = new Int32Array(shared);

  const workers = Array.from({ length: 4 }, () =>
    new Worker(new URL(import.meta.url), { workerData: shared }),
  );

  await Promise.all(workers.map((w) => new Promise((r) => w.on("exit", r))));
  // Atomics.add is correct here. Nothing would have stopped a plain
  // view[0] += 1, which is the same data race Python has.
  console.log(view[0]);
} else {
  const view = new Int32Array(workerData);
  for (let i = 0; i < 1000; i++) Atomics.add(view, 0, 1);
  parentPort?.close();
}

The Python example contains a real data race that will pass every test you write. The JavaScript one shows the alternative: one thread, no races, and no parallelism either.

The tools

ToolFor
thread::spawnan independent unit of work. Returns a handle you join
thread::scopethreads that finish before the scope ends, so they can borrow locals
mpsc::channelmany senders, one receiver. The default way to move data between threads
Arc<Mutex<T>>shared mutable state. lock() is the only way in
Arc<RwLock<T>>many readers or one writer, when reads dominate
AtomicUsize and friendsa single counter or flag, with no lock at all
OnceLock, LazyLockinitialise once, read from anywhere, no unsafe
rayonpar_iter(), and a parallel version of a sequential chain for one word of change
crossbeambetter channels, scoped threads, and lock free structures
  1. Prefer channels to shared state

    Moving data between threads means only one thread owns it at a time, and the type system enforces that for free. Shared state with a lock is the fallback for when the data really is shared.

  2. The data lives inside the lock

    Mutex<T> contains the T. There is no way to reach the data without calling lock(), so the bug where someone forgot to take the lock cannot be written. This is the single best design decision in the whole concurrency story.

  3. The guard unlocks when it drops

    No unlock call, and no way to forget one. A lock held longer than intended is usually a guard living longer than intended, and the fix is a smaller scope: { let mut v = m.lock().unwrap(); *v += 1; }.

  4. For data parallelism, try rayon first

    Changing .iter() to .par_iter() spreads the work across every core, with work stealing, and the compiler still checks that what you are doing is safe. For anything embarrassingly parallel this is a one word change with no risk attached.

async fn fetch(id: u32) -> Result<String, Error>

async and await

The syntax is in the language and the runtime is a crate. That split explains most of what is good and most of what is awkward about async Rust.

An async fn does not run when you call it. It returns a future, which is a state machine the compiler generated, and the future does nothing until something polls it. That something is a runtime, and Rust does not ship one, because a language that runs on microcontrollers cannot assume a thread pool and a timer wheel.

In practice the runtime is tokio. It is not the only one, and it is the one nearly every library targets.

async_demo.rs
//! async/await. An async fn returns a future, which does nothing until
//! something polls it. Rust ships the syntax and the traits; the runtime that
//! does the polling is a crate, and in practice that crate is tokio.

use std::time::Duration;
use tokio::time::sleep;

/// Calling this does not start any work. It builds a future and hands it
/// back. That is the difference from a JavaScript promise, which is already
/// running by the time you hold it.
async fn fetch_user(id: u32) -> Result<String, String> {
    sleep(Duration::from_millis(50)).await;
    if id == 0 {
        return Err(String::from("no user zero"));
    }
    Ok(format!("user-{id}"))
}

async fn fetch_orders(user: &str) -> Vec<String> {
    sleep(Duration::from_millis(30)).await;
    vec![format!("{user}-order-1"), format!("{user}-order-2")]
}

#[tokio::main]
async fn main() {
    // Sequential: each await finishes before the next one starts.
    let user = fetch_user(1).await.expect("user 1 exists");
    let orders = fetch_orders(&user).await;
    println!("{user} has {} orders", orders.len());

    // Concurrent: join polls all of them on this one task, so the waiting
    // overlaps. No threads are involved.
    let (a, b, c) = tokio::join!(fetch_user(2), fetch_user(3), fetch_user(4));
    println!("{a:?} {b:?} {c:?}");

    // try_join stops at the first error, which is usually what you want.
    match tokio::try_join!(fetch_user(5), fetch_user(0)) {
        Ok(pair) => println!("both worked: {pair:?}"),
        Err(err) => println!("one failed: {err}"),
    }

    // spawn moves a task onto the runtime, where other threads can steal it.
    // The future must be Send, which the compiler checks.
    let handle = tokio::spawn(async {
        sleep(Duration::from_millis(10)).await;
        "from a spawned task"
    });
    println!("{}", handle.await.expect("task should not panic"));

    // A timeout wraps any future at all.
    let slow = sleep(Duration::from_secs(5));
    match tokio::time::timeout(Duration::from_millis(20), slow).await {
        Ok(()) => println!("finished in time"),
        Err(_) => println!("timed out, and the future was dropped"),
    }

    // select! takes whichever finishes first and cancels the rest, by
    // dropping them. Cancellation in Rust is a drop, which is why holding a
    // lock across an await is a good way to be surprised.
    tokio::select! {
        user = fetch_user(6) => println!("user first: {user:?}"),
        () = sleep(Duration::from_millis(5)) => println!("timer first"),
    }
}
Python 3.12
import asyncio


async def fetch_user(user_id: int) -> str:
    await asyncio.sleep(0.05)
    if user_id == 0:
        raise ValueError("no user zero")
    return f"user-{user_id}"


async def main() -> None:
    # A coroutine starts running only when awaited, which is the same as
    # Rust. Forgetting the await gives you a warning and a coroutine object.
    user = await fetch_user(1)
    print(user)

    # Concurrency, with the failure mode that gather returns exceptions
    # mixed into the results list unless you ask otherwise.
    results = await asyncio.gather(
        fetch_user(2), fetch_user(3), fetch_user(0), return_exceptions=True
    )
    print(results)

    # A timeout cancels the task by raising inside it, so cleanup runs in
    # except and finally blocks. Rust cancels by dropping the future, which
    # runs destructors instead.
    try:
        await asyncio.wait_for(asyncio.sleep(5), timeout=0.02)
    except TimeoutError:
        print("timed out")

    # There is one runtime, it is in the standard library, and everything
    # async in the ecosystem targets it. That is the part Rust does not have.


if __name__ == "__main__":
    asyncio.run(main())
JavaScript (Node 22)
export async function fetchUser(id) {
  await new Promise((resolve) => setTimeout(resolve, 50));
  if (id === 0) throw new Error("no user zero");
  return `user-${id}`;
}

// A promise is already running by the time you hold it. Rust's futures are
// not: nothing happens until something polls them, which is why an unawaited
// future is a warning rather than work quietly happening in the background.
const started = fetchUser(1);
console.log("this line runs while the request is in flight");
console.log(await started);

// Concurrency. allSettled is the one that does not lose the other results
// when a single request fails.
console.log(await Promise.allSettled([fetchUser(2), fetchUser(3), fetchUser(0)]));

// There is no cancellation. AbortController asks nicely and only works if
// the thing you called bothered to listen. Dropping a Rust future stops it.
const controller = new AbortController();
setTimeout(() => controller.abort(), 20);
try {
  const response = await fetch("https://example.com", { signal: controller.signal });
  console.log(response.status);
} catch (problem) {
  console.log(`aborted: ${problem.name}`);
}

Python's asyncio is in the standard library, which settles the runtime question. JavaScript promises start running immediately, which is the deepest difference from Rust's futures.

Lazy futures, and what that changes

RustJavaScriptPython
Calling an async functionbuilds a future, runs nothingstarts running immediatelybuilds a coroutine, runs nothing
Forgetting to awaita warning, and no work happensthe work happens anyway, unobserveda warning, and no work happens
Cancellingdrop the future, destructors runnot really possibleraises inside the task
Where it runswhichever runtime you chosethe one event loopthe asyncio loop
  1. async fn colours your functions

    An async function can only be awaited from another async function, so async spreads up the call stack. This is the same complaint people have about Python and JavaScript, and it is more visible here because the sync and async versions of a library are often different crates.

  2. Use async when you are IO bound and have many connections

    Thousands of sockets waiting on a network is what it is for. For a hundred concurrent things, ordinary threads are simpler, and rather faster than people expect. For CPU bound work, use rayon and do not go near async.

  3. Never block inside an async task

    A std::thread::sleep, a synchronous file read, or a long computation inside a task blocks the whole worker thread and everything scheduled on it. Use tokio::task::spawn_blocking for those, which is what it exists for.

  4. Cancellation is a drop, and that surprises people

    select! drops the losing futures, so any work partway through simply stops at its last await point. Anything that must complete needs to be in a spawned task, not in a branch of a select.

Router::new().route("/tasks", get(list))

Build: a JSON API

axum on tokio, with shared state behind an Arc and a Mutex. Everything from the last three chapters in one file that runs.

A handler is an async function whose arguments say what it needs from the request and whose return type says what the response is. There is no framework specific request object to learn: the extractors are types, and adding one to the signature is how you ask for it.

src/main.rs
//! A JSON API with axum. One handler per route, shared state behind an Arc,
//! and the whole thing on tokio's work stealing runtime.

use axum::{
    extract::{Path, State},
    http::StatusCode,
    routing::{get, post},
    Json, Router,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};

#[derive(Debug, Clone, Serialize, Deserialize)]
struct Task {
    id: u32,
    title: String,
    done: bool,
}

#[derive(Debug, Deserialize)]
struct NewTask {
    title: String,
}

/// Shared state. Arc makes it shareable between threads, Mutex makes the
/// access exclusive. The compiler will not let a handler touch the map
/// without going through the lock.
#[derive(Clone, Default)]
struct AppState {
    tasks: Arc<Mutex<HashMap<u32, Task>>>,
    next_id: Arc<Mutex<u32>>,
}

#[tokio::main]
async fn main() {
    let state = AppState::default();

    let app = Router::new()
        .route("/tasks", get(list_tasks).post(create_task))
        .route("/tasks/{id}", get(get_task))
        .route("/health", get(|| async { "ok" }))
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
        .await
        .expect("port 3000 should be free");

    println!("listening on http://127.0.0.1:3000");
    axum::serve(listener, app).await.expect("server should run");
}

/// An async handler. The return type says what the response will be, and
/// axum turns it into JSON with the right content type.
async fn list_tasks(State(state): State<AppState>) -> Json<Vec<Task>> {
    let tasks = state.tasks.lock().expect("lock should not be poisoned");
    let mut list: Vec<Task> = tasks.values().cloned().collect();
    list.sort_by_key(|task| task.id);
    Json(list)
}

async fn get_task(
    State(state): State<AppState>,
    Path(id): Path<u32>,
) -> Result<Json<Task>, StatusCode> {
    let tasks = state.tasks.lock().expect("lock should not be poisoned");
    tasks.get(&id).cloned().map(Json).ok_or(StatusCode::NOT_FOUND)
}

async fn create_task(
    State(state): State<AppState>,
    Json(input): Json<NewTask>,
) -> (StatusCode, Json<Task>) {
    let id = {
        let mut next = state.next_id.lock().expect("lock should not be poisoned");
        *next += 1;
        *next
    };

    let task = Task { id, title: input.title, done: false };
    state
        .tasks
        .lock()
        .expect("lock should not be poisoned")
        .insert(id, task.clone());

    (StatusCode::CREATED, Json(task))
}
Cargo.toml
[package]
name = "taskserver"
version = "0.1.0"
edition = "2024"
rust-version = "1.85"

[dependencies]
# Ranges are caret by default: "1.0" means >=1.0.0 and <2.0.0.
anyhow = "1.0"
thiserror = "2.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
clap = { version = "4.5", features = ["derive"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time"] }
axum = "0.8"
rand = "0.9"

[dev-dependencies]
# Only compiled for tests and benchmarks, never in the shipped binary.
criterion = "0.5"
proptest = "1"

[profile.release]
# Slower to build, faster to run, and smaller.
opt-level = 3
lto = "thin"
codegen-units = 1
strip = "debuginfo"

[profile.dev]
# Dependencies optimised, your own code not, which keeps builds quick while
# leaving them fast enough to test against.
opt-level = 0

[profile.dev.package."*"]
opt-level = 2

What the type signatures are doing

  1. Extractors are arguments

    Path(id): Path<u32> parses the path segment and gives you a u32, or returns a 400 before your code runs. Json(input): Json<NewTask> parses and validates the body against the struct. A malformed request never reaches your handler.

  2. Return types are responses

    Json<Vec<Task>> serialises and sets the content type. Result<Json<Task>, StatusCode> gives you a 404 with ok_or and one line. Anything implementing IntoResponse works, including your own error type.

  3. State is shared with Arc, changed under a Mutex

    Handlers run on many threads at once, so the state has to be Send + Sync. The compiler checks it. Reaching the map without taking the lock is not something you can write.

  4. The lock scope is deliberate

    In create_task the id is taken in its own block so the guard drops before the next lock. Two locks held at once, in a handler that runs concurrently, is how deadlocks are made.

Try it yourself

Add PATCH /tasks/{id} to mark a task done, and make every handler return your own error type that implements IntoResponse, so the bodies can use ? instead of ok_or.

Hint: An enum with NotFound and Internal, a From<anyhow::Error> impl, and IntoResponse turning each into a status plus a JSON body.

Show one solution
server.rs
//! A JSON API with axum. One handler per route, shared state behind an Arc,
//! and the whole thing on tokio's work stealing runtime.

use axum::{
    extract::{Path, State},
    http::StatusCode,
    routing::{get, post},
    Json, Router,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};

#[derive(Debug, Clone, Serialize, Deserialize)]
struct Task {
    id: u32,
    title: String,
    done: bool,
}

#[derive(Debug, Deserialize)]
struct NewTask {
    title: String,
}

/// Shared state. Arc makes it shareable between threads, Mutex makes the
/// access exclusive. The compiler will not let a handler touch the map
/// without going through the lock.
#[derive(Clone, Default)]
struct AppState {
    tasks: Arc<Mutex<HashMap<u32, Task>>>,
    next_id: Arc<Mutex<u32>>,
}

#[tokio::main]
async fn main() {
    let state = AppState::default();

    let app = Router::new()
        .route("/tasks", get(list_tasks).post(create_task))
        .route("/tasks/{id}", get(get_task))
        .route("/health", get(|| async { "ok" }))
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
        .await
        .expect("port 3000 should be free");

    println!("listening on http://127.0.0.1:3000");
    axum::serve(listener, app).await.expect("server should run");
}

/// An async handler. The return type says what the response will be, and
/// axum turns it into JSON with the right content type.
async fn list_tasks(State(state): State<AppState>) -> Json<Vec<Task>> {
    let tasks = state.tasks.lock().expect("lock should not be poisoned");
    let mut list: Vec<Task> = tasks.values().cloned().collect();
    list.sort_by_key(|task| task.id);
    Json(list)
}

async fn get_task(
    State(state): State<AppState>,
    Path(id): Path<u32>,
) -> Result<Json<Task>, StatusCode> {
    let tasks = state.tasks.lock().expect("lock should not be poisoned");
    tasks.get(&id).cloned().map(Json).ok_or(StatusCode::NOT_FOUND)
}

async fn create_task(
    State(state): State<AppState>,
    Json(input): Json<NewTask>,
) -> (StatusCode, Json<Task>) {
    let id = {
        let mut next = state.next_id.lock().expect("lock should not be poisoned");
        *next += 1;
        *next
    };

    let task = Task { id, title: input.title, done: false };
    state
        .tasks
        .lock()
        .expect("lock should not be poisoned")
        .insert(id, task.clone());

    (StatusCode::CREATED, Json(task))
}
// SAFETY: mid <= len was checked above

unsafe, and what it does not mean

unsafe unlocks five specific abilities and turns nothing off. Most people never write it, and everyone benefits from knowing what it is for.

The borrow checker is conservative: it rejects some programs that are perfectly correct, because it cannot prove they are. unsafe is how you tell it that you have proved it yourself.

It does not disable the borrow checker, the type system or anything else. Inside an unsafe block you gain exactly five abilities: dereference a raw pointer, call an unsafe function, implement an unsafe trait, access a mutable static, and access a union field. Everything else works as it always did.

unsafe_demo.rs
//! unsafe does not turn the borrow checker off. It unlocks five specific
//! abilities, and it means you are now responsible for the invariants the
//! compiler was checking. The job of an unsafe block is to be small and to
//! have a comment above it saying why it is sound.

/// Splitting a slice into two mutable halves. Safe Rust cannot express this,
/// because it would mean two exclusive borrows of the same slice, and the
/// compiler cannot see that the halves do not overlap. The standard library
/// provides split_at_mut for exactly this reason; here is what is inside it.
pub fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = values.len();
    let pointer = values.as_mut_ptr();

    assert!(mid <= len, "mid must be within the slice");

    // SAFETY: mid <= len was just checked, so both ranges are inside the
    // allocation, and they do not overlap, so the two &mut are disjoint.
    unsafe {
        (
            std::slice::from_raw_parts_mut(pointer, mid),
            std::slice::from_raw_parts_mut(pointer.add(mid), len - mid),
        )
    }
}

/// Calling into C. extern declares the signature; the compiler has to take
/// your word for it, which is why the block is unsafe.
unsafe extern "C" {
    fn abs(input: i32) -> i32;
}

pub fn c_abs(value: i32) -> i32 {
    // SAFETY: abs has no preconditions and cannot fail for any i32.
    unsafe { abs(value) }
}

/// A raw pointer can be created in safe code. Only dereferencing it is
/// unsafe, which is a useful split: building one proves nothing, using one
/// is the claim.
pub fn raw_pointers() {
    let value = 42;
    let pointer = &value as *const i32;

    // SAFETY: pointer came from a live reference to a local that is still in
    // scope, so it is aligned, non null and points at an initialised i32.
    let read = unsafe { *pointer };
    println!("read {read} through a raw pointer");
}

fn main() {
    let mut numbers = [1, 2, 3, 4, 5, 6];
    let (left, right) = split_at_mut(&mut numbers, 3);
    left[0] = 100;
    right[0] = 200;
    println!("{numbers:?}");

    println!("{}", c_abs(-7));
    raw_pointers();
}

The convention that makes it manageable

  1. Keep the block as small as the operation

    Wrap the two lines that dereference, not the twenty around them. A large unsafe block is a large surface to audit.

  2. Write a SAFETY comment above every one

    State the invariant you are relying on and why it holds here. The standard library does this everywhere, clippy has a lint for it, and it is the difference between a reviewable claim and a shrug.

  3. Wrap it in a safe interface

    split_at_mut is safe to call: no arguments can make it do anything unsound, because it checks. That is the goal every time. An unsafe implementation behind a safe API is fine; a safe looking API that is actually unsound is the bug.

  4. Test it with the tools that exist for this

    Miri interprets your code and detects undefined behaviour that no amount of ordinary testing will find. cargo +nightly miri test. Also address and thread sanitisers through -Z sanitizer.

When you actually need it

ReasonNote
Calling C or being called from CThe main one. FFI is inherently a promise the compiler cannot check
Data structures the borrow checker cannot expressIntrusive lists, some lock free structures. Almost always: use a crate that has already done it and been audited
Hardware and embeddedMemory mapped registers are raw pointers by definition
A measured hot spotSkipping a bounds check that a profiler says matters. Measure first, then check that the optimiser had not already removed it
macro_rules! my_vec { ($($item:expr),+) => { ... } }

Macros

Macros work on syntax rather than values, which is how vec! takes any number of arguments and how #[derive(Serialize)] writes a parser for your struct.

A function receives values. A macro receives tokens, before the compiler has worked out what they mean, and produces more tokens. That is why println! can check its format string against its arguments at compile time and why vec! can take a variable number of them, neither of which a Rust function can do.

macros.rs
//! Macros run at compile time and work on syntax rather than values. That is
//! how vec! can take any number of arguments and how println! can check its
//! format string.

/// A declarative macro. Each rule is a pattern and an expansion, and the
/// fragment specifiers say what kind of syntax each capture accepts:
/// expr for an expression, ident for a name, ty for a type, tt for anything.
macro_rules! my_vec {
    // No arguments.
    () => {
        Vec::new()
    };
    // One or more expressions, with an optional trailing comma.
    ($($item:expr),+ $(,)?) => {{
        let mut items = Vec::new();
        $(
            items.push($item);
        )+
        items
    }};
}

/// A macro earning its place: this cannot be a function, because a function
/// cannot see the text of its argument.
macro_rules! show {
    ($value:expr) => {
        println!("{} = {:?}", stringify!($value), $value)
    };
}

/// Macros can generate items, not just expressions. This writes a whole impl
/// block for each type it is given.
macro_rules! impl_describe {
    ($($type:ty => $label:expr),* $(,)?) => {
        $(
            impl Describe for $type {
                fn describe(&self) -> String {
                    format!("{} ({})", self, $label)
                }
            }
        )*
    };
}

trait Describe {
    fn describe(&self) -> String;
}

impl_describe! {
    i32 => "a signed integer",
    f64 => "a float",
    bool => "a boolean",
}

fn main() {
    let empty: Vec<i32> = my_vec![];
    let numbers = my_vec![1, 2, 3];
    println!("{empty:?} {numbers:?}");

    let total = numbers.iter().sum::<i32>();
    show!(total);
    show!(numbers.len() * 2);

    println!("{}", 42.describe());
    println!("{}", 1.5.describe());
    println!("{}", true.describe());

    // The other kind is a procedural macro, which is a compiler plugin in its
    // own crate. #[derive(Debug)], #[derive(Serialize)] and #[tokio::main]
    // are all procedural macros: they take a token stream and give one back.
    // Writing one is a real project. Using them is most of modern Rust.
    println!("{:?}", std::any::type_name::<Vec<i32>>());
}

The two kinds

DeclarativeProcedural
Written asmacro_rules!a function in its own crate
Works bypattern matching on token treesarbitrary Rust code over a TokenStream
Lives inany modulea separate proc-macro crate
Examplesvec!, println!, matches!#[derive(Debug)], #[tokio::main], #[derive(Serialize)]
Effortan afternoona project, plus syn and quote
Fragment specifierMatches
$x:expran expression
$x:identa name
$x:tya type
$x:pata pattern
$x:literala literal
$x:blocka braced block
$x:stmt, $x:itema statement, an item such as a fn or struct
$x:ttany single token tree, the most permissive
$($x:expr),*zero or more, comma separated
$($x:expr),+ $(,)?one or more, with an optional trailing comma
#[test] fn splits_evenly() { assert_eq!(..) }

Testing

Tests live next to the code, run with one command, need no framework, and your documentation examples are tests too.

cargo test runs three things: unit tests inside your modules, integration tests in tests/, and every code example in your documentation. The third one is the unusual part, and it means documentation that has drifted out of date fails the build rather than quietly misleading people.

src/lib.rs
//! Tests live next to the code they test, run with cargo test, and need no
//! framework. Doc examples are compiled and run too, so documentation that
//! drifts out of date fails the build.

/// Split a bill between people, rounding to whole cents.
///
/// The example below is a real test. cargo test compiles it, runs it, and
/// fails if the assertion does not hold.
///
/// ```
/// # use testing::split_bill;
/// assert_eq!(split_bill(1000, 3), vec![334, 333, 333]);
/// assert_eq!(split_bill(1000, 1), vec![1000]);
/// ```
pub fn split_bill(total_cents: u64, people: u64) -> Vec<u64> {
    if people == 0 {
        return Vec::new();
    }
    let base = total_cents / people;
    let remainder = total_cents % people;
    (0..people)
        .map(|index| if index < remainder { base + 1 } else { base })
        .collect()
}

pub fn is_palindrome(text: &str) -> bool {
    let letters: Vec<char> = text
        .chars()
        .filter(|c| c.is_alphanumeric())
        .flat_map(|c| c.to_lowercase())
        .collect();
    letters.iter().eq(letters.iter().rev())
}

/// #[cfg(test)] means this module is only compiled when testing, so test
/// code and its dependencies never reach the release binary.
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn splits_evenly() {
        assert_eq!(split_bill(900, 3), vec![300, 300, 300]);
    }

    #[test]
    fn gives_the_extra_cents_to_the_first_people() {
        let shares = split_bill(1000, 3);
        assert_eq!(shares.iter().sum::<u64>(), 1000);
        assert_eq!(shares[0], 334);
    }

    #[test]
    fn nobody_to_split_between() {
        assert!(split_bill(1000, 0).is_empty());
    }

    /// A test can return Result, so ? works inside it and a failure to parse
    /// is a failed test rather than a panic with a worse message.
    #[test]
    fn parsing_works() -> Result<(), std::num::ParseIntError> {
        let value: u64 = "1000".parse()?;
        assert_eq!(split_bill(value, 4).len(), 4);
        Ok(())
    }

    #[test]
    fn palindromes() {
        assert!(is_palindrome("A man, a plan, a canal: Panama"));
        assert!(!is_palindrome("borrow checker"));
    }

    #[test]
    #[should_panic(expected = "index out of bounds")]
    fn indexing_past_the_end_panics() {
        let shares = split_bill(100, 2);
        let _ = shares[5];
    }

    #[test]
    #[ignore = "slow, run with cargo test -- --ignored"]
    fn large_split() {
        assert_eq!(split_bill(1_000_000, 7).len(), 7);
    }
}
cargo test
$ cargo test
   Compiling wordcount v0.1.0 (/home/you/wordcount)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running unittests src/lib.rs

running 5 tests
test tests::counts_repeats ... ok
test tests::empty_input_has_no_words ... ok
test tests::gives_the_extra_cents_to_the_first_people ... ok
test tests::ranks_by_count_then_alphabetically ... ok
test tests::splits_on_punctuation ... FAILED

failures:

---- tests::splits_on_punctuation stdout ----
thread 'tests::splits_on_punctuation' panicked at src/lib.rs:63:9:
assertion `left == right` failed
  left: ["one", "two!"]
 right: ["one", "two"]
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

failures:
    tests::splits_on_punctuation

test result: FAILED. 4 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests wordcount

running 1 test
test src/lib.rs - split_bill (line 8) ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

The layout

WhereSeesFor
#[cfg(test)] mod tests in the fileprivate items toounit tests of internals
tests/*.rsonly the public APIintegration tests, one binary each
/// ``` in a doc commentthe public APIexamples that cannot go stale
benches/*.rsthe public APIbenchmarks, usually with criterion
examples/*.rsthe public APIprograms, compiled by cargo test
  1. assert_eq! prints both sides

    Which is why it is worth using over assert!(a == b). Both values need Debug, which is one more reason to derive it everywhere. assert!(matches!(value, Pattern)) covers the case where you only care about the shape.

  2. A test can return Result

    Then ? works inside it, and a parse failure is a failed test with a useful message rather than a panic in the middle of setup.

  3. #[should_panic(expected = "...")]

    For asserting that something does refuse. The expected string has to appear in the panic message, so the test does not pass because of some unrelated panic.

  4. Tests run in parallel by default

    Which is fast and means shared external state will bite you. cargo test -- --test-threads=1 when it does, and consider whether the test should own its own temporary directory instead.

The crates worth adding

CrateFor
proptest or quickcheckgenerate hundreds of inputs and assert properties instead of examples. Both shrink a failure to the smallest input that still breaks
instasnapshot testing. Assert that output matches a stored file, and review changes as a diff
criterionbenchmarks with statistics, so you can tell a real regression from noise
cargo-nextesta faster runner with much better output and per test process isolation
rstestparameterised tests and fixtures, close to pytest
mockallmocks, for the cases where a trait boundary is not enough
tempfilea temporary directory that cleans itself up when dropped
Vec::with_capacity(n)

Making it fast, and knowing when not to

Rust is fast by default. Most of the remaining work is about allocations and copies, and all of it should start with a measurement.

The first performance question is always whether you built in release mode. A debug build keeps every overflow check and inlines nothing, and is routinely ten to fifty times slower. Every benchmark where Rust loses to Python is this.

perf.rs
//! Making Rust fast is mostly about allocations, copies and not fighting the
//! optimiser. These are the changes that show up in a profile.

use std::collections::HashMap;

/// Allocates a String for every call, and takes ownership of one it does not
/// need. Both are avoidable.
pub fn shouty_slow(words: &[String]) -> Vec<String> {
    let mut out = Vec::new();
    for word in words {
        let upper = word.to_uppercase();
        out.push(upper);
    }
    out
}

/// with_capacity when the size is known, so the vector grows once instead of
/// doubling its way there.
pub fn shouty_faster(words: &[String]) -> Vec<String> {
    let mut out = Vec::with_capacity(words.len());
    out.extend(words.iter().map(|word| word.to_uppercase()));
    out
}

/// Taking &str instead of String means callers with a literal, a slice or an
/// owned String all pass without allocating.
pub fn initial(name: &str) -> Option<char> {
    name.chars().next()
}

/// Cow, for the case where usually nothing needs to change. It borrows when
/// it can and allocates only when it has to.
pub fn normalise(input: &str) -> std::borrow::Cow<'_, str> {
    if input.contains("  ") {
        std::borrow::Cow::Owned(input.split_whitespace().collect::<Vec<_>>().join(" "))
    } else {
        std::borrow::Cow::Borrowed(input)
    }
}

/// entry() looks the key up once. The obvious version with contains_key and
/// then insert looks it up twice, and hashing is not free.
pub fn count(words: &[&str]) -> HashMap<String, usize> {
    let mut counts = HashMap::with_capacity(words.len());
    for word in words {
        *counts.entry(word.to_string()).or_insert(0) += 1;
    }
    counts
}

/// Static dispatch: one copy per type, calls inlined, larger binary.
pub fn total_static<T: Into<f64> + Copy>(values: &[T]) -> f64 {
    values.iter().map(|v| (*v).into()).sum()
}

/// Dynamic dispatch: one copy, a pointer chase per call, smaller binary and
/// the ability to hold different types in one collection. Reach for it when
/// the collection needs to be mixed, not because it feels tidier.
pub fn describe_all(items: &[Box<dyn std::fmt::Debug>]) -> usize {
    items.len()
}

fn main() {
    let words: Vec<String> = ["one", "two", "three"].iter().map(|s| s.to_string()).collect();
    println!("{:?}", shouty_slow(&words));
    println!("{:?}", shouty_faster(&words));
    println!("{:?}", initial("ada"));
    println!("{}", normalise("too   many    spaces"));
    println!("{:?}", count(&["a", "b", "a"]).len());
    println!("{}", total_static(&[1i32, 2, 3]));

    // Release mode matters more here than in most languages. Debug builds
    // keep every overflow check and inline nothing, and are routinely ten to
    // fifty times slower. Always measure with cargo run --release.
    println!("built in {} mode", if cfg!(debug_assertions) { "debug" } else { "release" });
}

Where the time usually goes

CostFix
Allocating in a loopVec::with_capacity, or reuse one buffer across iterations
Cloning to satisfy the borrow checkerRestructure so a borrow works, or use Cow when it usually does
String argumentsTake &str, so callers with a literal do not allocate
collect() in the middle of a chainKeep it lazy until the end. Each collect is an allocation and a pass
Double lookups in a HashMapentry(), which hashes once
The default hasherIt is SipHash, chosen to resist collision attacks. For internal maps with trusted keys, rustc-hash is much faster
Bounds checks in a hot loopUsually already removed. Iterators let the optimiser prove the index is in range, which indexing by hand does not
  1. Measure before changing anything

    criterion for microbenchmarks, since it does the statistics and tells you whether a difference is real. samply or perf for a flamegraph of a whole program. dhat or heaptrack when the suspicion is allocation.

  2. Tune the release profile

    lto = "thin" and codegen-units = 1 are usually worth a few percent for a slower build. panic = "abort" removes the unwinding tables if you never catch panics.

  3. Reach for rayon before anything clever

    Changing .iter() to .par_iter() uses every core, and the compiler still checks that it is safe. For work that is genuinely parallel this beats any amount of single threaded micro optimisation.

  4. Only then consider unsafe

    And check first that the optimiser had not already done what you were about to do by hand. It usually had.

cargo clippy -- -D warnings

Tooling, the ecosystem and where to go next

The commands worth binding to keys, the crates worth knowing by name, and what to read after this.

The commands you will use daily
# Start a project. --lib for a library, no flag for a binary.
cargo new taskserver
cd taskserver

cargo build            # debug build, fast to compile, slow to run
cargo build --release  # optimised, and what you should benchmark
cargo run -- --help    # arguments after -- go to your program
cargo check            # type check with no code generation, much faster
cargo test             # unit tests, integration tests and doc examples
cargo doc --open       # build and read your own documentation

# The two that should run in CI from the first commit.
cargo clippy -- -D warnings
cargo fmt --check

# Dependencies.
cargo add serde --features derive
cargo add tokio --features rt-multi-thread,macros
cargo remove serde_json
cargo update            # move within the ranges in Cargo.toml
cargo tree              # see who pulled in what

# The extras worth installing.
cargo install cargo-watch cargo-audit cargo-nextest cargo-deny
cargo watch -x check -x test    # rerun on every save
cargo audit                     # known vulnerabilities in the tree
cargo nextest run               # a faster test runner with better output

Clippy is a teacher

Clippy knows several hundred patterns of unidiomatic Rust and explains each one. Reading its suggestions for a week is one of the faster ways to stop writing Rust like the language you came from. Turn it on in CI with -D warnings from the first commit, while there is nothing to fix.

cargo clippy
$ cargo clippy -- -D warnings
warning: this loop could be written as a `for` loop
 --> src/main.rs:9:5
  |
9 |     while let Some(item) = iter.next() {
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ help: try: `for item in iter`
  |
  = note: `#[warn(clippy::while_let_on_iterator)]` on by default
warning: redundant clone
  --> src/main.rs:14:33
   |
14 |     let name = user.name.clone().to_string();
   |                                 ^^^^^^^^^^^^ help: remove this
   |
note: this value is dropped without further use
   = note: `#[warn(clippy::redundant_clone)]` on by default
error: could not compile `taskserver` (bin "taskserver") due to 2 previous errors

Crates worth knowing by name

NeedCrate
Serialisationserde, with serde_json, toml, bincode
Command lineclap
Errorsthiserror in libraries, anyhow in binaries
Async runtimetokio
HTTP serveraxum, or actix-web
HTTP clientreqwest
Databasessqlx for checked SQL, sea-orm or diesel for an ORM
Loggingtracing with tracing-subscriber
Data parallelismrayon
Regular expressionsregex, which has no catastrophic backtracking by design
Dates and timesjiff, or chrono in older code
Random numbersrand
Testingproptest, insta, criterion
Python bindingspyo3 with maturin
Node bindingsnapi-rs
WebAssemblywasm-bindgen with wasm-pack

Calling Rust from what you already have

The most realistic first Rust in production is not a rewrite. It is one function that was too slow, moved behind the interface it already had. PyO3 and maturin turn a Rust crate into a Python wheel that imports like any other module. napi-rs does the same for Node.

Targets and bindings
# One toolchain, many targets. Adding a target is one command.
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl

# musl gives a static binary with no libc dependency, which is why Rust
# containers can be FROM scratch and a few megabytes.

# WebAssembly, for the browser or for a plugin host.
rustup target add wasm32-unknown-unknown
cargo install wasm-pack
wasm-pack build --target web

# Calling Rust from Python, via PyO3 and maturin.
pip install maturin
maturin develop --release

# Calling Rust from Node, via napi-rs.
npm install -g @napi-rs/cli
napi build --platform --release

Habits that make Rust pleasant

  1. cargo check on every save

    The build is the slow part and you rarely need it. cargo watch -x check -x test gives you a loop that is quick enough to think in.

  2. Read the whole error, from the bottom

    The help and note lines at the end are usually the answer, and often contain the exact code to paste. Rust's errors are the best in any mainstream language and skimming them wastes the one thing that makes learning it bearable.

  3. Clone while you are learning

    An hour arguing with the borrow checker to avoid one allocation in a setup path is a bad trade. Get it working, then come back if a profile complains. Nobody who has written Rust for years is fighting it; they are also not avoiding every clone.

  4. Own your data at the boundaries

    String in structs, &str in arguments. Lifetimes in a public struct spread outward until everything has one. Take the allocation and keep the signature simple.

  5. Let the types carry the rules

    A newtype instead of a bare u64, an enum instead of a string, Option instead of a sentinel. Each one moves a class of bug from runtime to compile time, and none costs anything at runtime.

  6. Derive Debug on everything

    It costs nothing you will notice and it is the difference between a useful assertion failure and a useless one.

Where to go after this

the reference

The Book

doc.rust-lang.org/book. Free, official, thorough, and the standard answer for a reason. Read chapters 4, 10 and 15 even if you skip the rest.

by example

Rust by Example and Rustlings

Rust by Example for runnable snippets, and Rustlings for small exercises that fail until you fix them. Rustlings is the fastest way to make ownership stick.

deeper

Rust for Rustaceans

Jon Gjengset's book, for after this. It covers the parts that only come up in real projects: API design, variance, unsafe, and what the ecosystem actually expects of a crate.

the edges

The Rustonomicon and the async book

The Rustonomicon before writing any unsafe, and the async book when futures stop making sense.