Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Rust for Humans

Rust is worth learning, but most resources explain it like you already know it. This one doesn’t. It’s built for the person who reads a chapter, feels like they get it, then forgets half of it three days later. (That’s normal. It’s how brains work. This site is designed to fight it.)

How to use this site

You learn and remember by doing, not by reading. So every lesson gives you four ways to make it stick:

  1. Read the idea, slowly. Each topic is explained in plain English first — the why before the how. No wall of jargon.

  2. Run the code. Every code box has a ▶ Run button. It runs real Rust in your browser. Don’t just read it — change it, break it, run it again. Seeing the compiler yell at you (and then fixing it) is how the rules move from “I read that” to “I know that.”

    fn main() {
        let name = "you";
        println!("Hello, {name}! Try changing this line and press Run.");
    }
  3. Do the “Your turn” exercise. A small broken program you have to fix. If you can fix it, you understood it. If you can’t, the lesson above has the answer.

  4. Take the quick check. A couple of questions at the end of each lesson. Getting them wrong is useful — the explanation tells you exactly what you missed.

Then, whenever you feel Rust slipping away, open the Review & flashcards page. It reshuffles the key questions from everything you’ve learned and shows the ones you marked “shaky” first. Ten minutes there beats rereading a whole chapter.

The path

The lessons are ordered so each one builds on the last. Start at the top of the sidebar and work down:

  • Start here — install Rust, run your first program, read compiler errors without panicking.
  • Language basics — variables, types, functions, structs, enums, pattern matching.
  • Ownership — the part that feels hard at first and then makes everything else click.
  • Abstractions — traits, generics, iterators, closures, error handling.
  • Runtime & ecosystem — testing, concurrency, async, and the tools you use every day.

You don’t have to rush. Do one lesson, run every example, take the quiz. Come back tomorrow and do the next one. Slow and sticky beats fast and forgotten.

Ready? Open Rust at a glance.

Rust at a glance

Beginner · Start here

What & why

Before you learn any language, it helps to know what it’s for and what shape it has — a map before the streets. This page is that map. It tells you what Rust is good at, the one big idea that makes it different, and the order to learn things in so you don’t get lost. Read it once, don’t try to memorize it, and come back whenever you feel unsure where you are.

The idea, slowly

Rust is a language for writing programs that need to be fast and not crash. That’s the whole pitch. Big companies use it for web servers, command-line tools, game engines, operating systems, and parts of your browser. But you don’t need to build any of that. You just need to know why people reach for Rust, because that “why” explains every strange thing the language will ask of you.

The trade every language makes

Every programming language has to answer one hard question: who cleans up the memory?

When your program makes a value — a piece of text, a list of numbers, an image — the computer sets aside some memory to hold it. When you’re done with that value, the memory has to be handed back, or your program slowly eats the whole machine. There are three classic ways to handle this:

  • The garbage collector way (Python, JavaScript, Java, Go). A hidden helper runs in the background, notices when you’re done with things, and cleans up for you. Comfortable — but it pauses your program at random moments and costs speed.
  • The do-it-yourself way (C, C++). You clean up by hand. Total control and blazing speed — but forget once, clean up twice, or use a value after cleaning it, and you get crashes and security holes. This is where a huge share of real-world bugs come from.
  • The Rust way. You don’t clean up by hand, and there’s no background helper. Instead the compiler reads your code before it ever runs and works out exactly when each value is finished. If your code is unsafe, it refuses to build and tells you why.

That third path is the entire personality of Rust. You get the speed of the do-it-yourself way with the safety of the garbage-collected way. The price is that the compiler is strict, and while you’re learning it will say “no” a lot. That’s not the compiler being mean. Think of it as a very careful coworker reading over your shoulder, catching bugs at your desk instead of letting them reach real users at 2am.

“Explicit by default” — what that means for you

Rust likes you to say what you mean. It rarely does surprising things behind your back. A tiny example:

fn main() {
    println!("Rust is explicit by default");
}

That prints one line. Nothing hidden, nothing magic. As lessons go on you’ll notice Rust makes you spell things out — whether a value can change, who owns it, what type it is when it’s unclear. It feels like extra typing at first. The payoff is that when you read Rust code later, it tells you the truth about what it does. There are far fewer “wait, how did that happen?” moments.

The one idea to fear (a little) and then love

If you remember only one word from this whole page, remember ownership. It’s Rust’s rule for who is responsible for each value and when it gets cleaned up. Ownership is why the “who cleans up memory?” question gets answered for free. It’s also the thing that confuses every beginner for a few days and then suddenly clicks, after which the rest of the language makes sense. You have a whole lesson on it later. For now just know: it’s coming, it’s the heart of Rust, and struggling with it at first is completely normal.

The order that won’t overwhelm you

Rust is a big language, but you learn a small useful slice first and grow from there. A sane path:

  1. Get set up — install the tools, print “Hello, world”, learn to read the compiler’s error messages (that last skill is worth gold).
  2. Language basics — variables, types, functions, if/match, loops. This is the ordinary stuff every language has.
  3. Ownership and borrowing — the Rust-specific core. Go slow here.
  4. Everyday building blocks — structs, enums, and the standard library’s collections and strings.
  5. The ecosystem — error handling, testing, and pulling in other people’s code.

Don’t jump ahead to macros, unsafe, or async on day one. Those are advanced rooms in the house; you’ll find the doors when you’re ready.

Common mistakes

  • Trying to learn everything at once. Rust has a lot of surface area, and the docs are thorough to a fault. If you read the whole official book in one sitting you’ll drown. The map above exists so you can learn one slice, use it, and only then move on.
  • Starting with the hard, flashy features. Macros (the ! things), unsafe, and async look powerful and get talked about a lot online. They are not where you begin. Beginners who start there get discouraged fast. Orient with this page, then go to install and ownership.
  • Reading a compiler “no” as failure. When Rust rejects your code it’s doing its job — catching a bug before it ships. The error message usually contains the fix. Treat red text as help, not punishment.

More examples

Spot the safety net

In a scripting or garbage-collected language, handing a value to a new home while still holding onto the original name is completely normal. Rust asks first — and refuses to build code that tries it.

fn main() {
    let ticket = String::from("boarding-pass-482");
    let queue = vec![ticket];       // ticket's value moves into the vector
    println!("Printing: {ticket}"); // ERROR: `ticket` was already moved
    println!("Queue: {:?}", queue);
}

A systems mindset vs. a scripting mindset

A scripting language lets you write total = sum([19.99, 4.50, 12.00]) without a second thought. Rust wants the types settled before it builds anything at all — that upfront precision is what “systems language” buys you.

fn main() {
    let prices: [f64; 3] = [19.99, 4.50, 12.00];
    let total: f64 = prices.iter().sum();
    println!("Cart total: ${total:.2}");
}

Let Cargo run the whole project

You clone someone else’s Rust project and just want to try it, without first learning how its build is wired together.

cargo run

No silent number conversions

Your quantity comes from a small counter type and your price from a bigger currency type — Rust won’t quietly mix them for you, so the conversion has to show up in the code.

fn main() {
    let quantity: u8 = 200;
    let price_cents: u32 = 350;
    let total_cents = quantity as u32 * price_cents;
    println!("Total: {} cents", total_cents);
}

Errors as values, not exceptions

Some of your input will be messy — a form field, a config line, a CSV column — and instead of an exception that can crash the program if nobody catches it, Rust hands you back a value you’re required to look at.

fn main() {
    let entries = vec!["3", "7", "oops", "12"];
    for text in entries {
        match text.parse::<i32>() {
            Ok(n) => println!("{n} is a valid number"),
            Err(_) => println!("'{text}' is not a number -- no crash, just a value"),
        }
    }
}

Your turn

You can’t really “break” a map, so here’s a hands-on task instead. Run the program below as-is and read the output. Then change the text inside the quotes to your own sentence — maybe why you want to learn Rust — and run it again. Notice that nothing surprising happens: what you typed is exactly what prints. That predictability is the point.

fn main() {
    println!("Rust is explicit by default");
}
Show solution

There’s nothing to fix here — the goal is just to confirm the tool works and to feel how Rust does exactly what you wrote. For example:

fn main() {
    println!("I am learning Rust so my programs are fast and never crash.");
}

If your line printed, your setup works and you’re ready for the real lessons.

Quick check

Remember this

  • Rust exists to make programs that are both fast and safe from memory bugs, with no garbage collector.
  • The compiler checks your code before it runs and refuses to build unsafe code — strictness now saves crashes later.
  • Ownership is the core idea that makes everything else make sense; it’s coming in a later lesson.
  • Rust is explicit by default — it rarely does hidden things, so code tells you the truth.
  • Learn in slices: setup → basics → ownership → structs/enums → ecosystem. Skip macros and unsafe for now.

Go deeper

Next:

Install Rust

Beginner · Start here

What & why

Before you can run a single line of Rust on your own machine, you need the tools installed. The good news: there’s one official installer that sets up everything and keeps it updated, so you don’t have to hunt down pieces. This lesson walks you through it slowly and shows you how to check that it worked.

The idea, slowly

To write Rust locally you need three things, and they come as a bundle:

  • rustc — the compiler. It turns your Rust code into a program the computer can run.
  • cargo — the project manager. You’ll actually type cargo far more than rustc. It builds, runs, tests, and pulls in other people’s code. (It has its own lesson next.)
  • rustup — the installer and updater. It installs rustc and cargo, and later keeps them up to date.

Think of rustup as the app store, and rustc + cargo as the apps it installs and keeps current. You install rustup once; after that it manages the rest.

Why one installer instead of your system’s package manager

You might be tempted to run something like apt install rustc on Linux, or grab Rust from Homebrew. It usually works for a day and then hurts. Distro packages are often old, they’re hard to update on Rust’s fast release cycle, and they can’t easily switch between the stable, beta, and nightly versions of Rust. rustup is the official tool built exactly for this job. Use it unless you have a specific, known reason not to.

Installing it

Go to rustup.rs in your browser. It shows you the exact command for your operating system.

On macOS or Linux it’s a single line you paste into your terminal:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

On Windows, rustup.rs gives you a small installer to download and double-click (you’ll also want the Visual Studio C++ build tools, which the installer points you to). Either way, when it asks you to choose an option, just press Enter for the standard default install. The defaults are the right choice for beginners.

While it runs, here’s what the installer is “thinking”: download rustup, then use it to download the current stable compiler and cargo, then add them to your PATH so your terminal can find them. That last part — PATH — is the one that trips people up, so read the next section.

Making your terminal find the tools

After installing, the tools live in a folder like ~/.cargo/bin. Your terminal only runs commands it can find on its PATH (a list of folders it searches). The installer adds that folder to your PATH — but a terminal window that was already open won’t notice the change.

The fix is simple: close your terminal and open a new one (or run source "$HOME/.cargo/env"). This is the single most common “I installed it but nothing works” cause. If a command isn’t found, restart the terminal first, before you assume anything is actually broken.

Checking that it worked

Open a fresh terminal and run these three commands:

rustc --version
cargo --version
rustup --version

Each should print a version number, something like rustc 1.XX.0. If you see three version lines, you’re done — the compiler, the project manager, and the updater are all installed and reachable.

Keeping it current

Rust ships a new stable version roughly every six weeks. Updating is one command:

rustup update

That’s rustup doing its “app store” job: fetch the latest stable rustc and cargo and swap them in. Run it now and then; you don’t need to babysit it.

Common mistakes

  • Installing from a distro package or Homebrew “because it’s easier.” You often get an old version that’s a pain to update and can’t switch toolchains. When a tutorial assumes a recent Rust and yours is ancient, you’ll waste an afternoon confused. Use rustup from rustup.rs.
  • “Command not found” right after installing. This almost always means your open terminal hasn’t picked up the new PATH yet. Close it and open a new one (or source "$HOME/.cargo/env") before you start debugging. Restart first, panic later.
  • Skipping the version check. If you don’t run rustc --version and cargo --version, you won’t know whether the install actually took until something fails mid-lesson. Confirm up front — it takes five seconds.
  • On Windows, missing the C++ build tools. Rust needs a linker. The rustup installer tells you if you’re missing the Visual Studio build tools; follow its prompt rather than ignoring it, or builds will fail with a confusing linker error.

More examples

Checking exactly what’s installed

You’re on a new machine, or picking up a teammate’s laptop, and want to know if clippy or the WebAssembly target is already there before you rely on it.

rustup component list --installed

Adding a target so you can cross-compile

You’re building a CLI tool on your Mac but need to hand a binary to a colleague running Linux, or deploy it straight to a Linux server.

rustup target add x86_64-unknown-linux-gnu

Pinning one project to an older toolchain

A client’s codebase was written against an older Rust release, and upgrading it isn’t your call today — you don’t want to change your global default just for this one folder.

rustup override set 1.75.0

Refreshing a single component

rustfmt starts misbehaving after an OS update, but reinstalling your whole toolchain to fix one small piece feels like overkill.

rustup component remove rustfmt
rustup component add rustfmt

Seeing what’s active at a glance

You installed a nightly toolchain months ago to try one feature, and now you’re not sure which toolchain actually runs when you type cargo build.

rustup show

Your turn

This lesson has no code to fix — the exercise is to actually install and verify. Do this now:

  1. Go to rustup.rs and run the command it gives you for your OS, accepting the standard defaults.
  2. Close your terminal and open a brand-new one.
  3. Run the three checks below.
rustc --version
cargo --version
rustup --version
Show solution

Success looks like three lines, each with a version number, for example:

rustc 1.79.0 (129f3b996 2024-06-10)
cargo 1.79.0 (ffa9cf99a 2024-06-03)
rustup 1.27.1 (54dd3d00f 2024-04-24)

Your exact numbers will differ and will be higher than these. If instead you see “command not found”, open a new terminal (the PATH change hasn’t reached your old one) or run source "$HOME/.cargo/env", then try again. Three version lines means you’re ready for the next lesson.

Quick check

Remember this

  • Install with rustup from rustup.rs; accept the standard defaults.
  • rustup manages rustc (the compiler) and cargo (the project manager) and keeps them updated.
  • After installing, open a new terminal so PATH updates — this fixes most “command not found” cases.
  • Confirm with rustc --version and cargo --version; both should print version numbers.
  • Update anytime with rustup update. Prefer rustup over distro/Homebrew packages.

Go deeper

Next:

Cargo basics

Beginner · Start here

What & why

Cargo is the tool you’ll type more than any other in Rust. It starts new projects, builds them, runs them, runs your tests, and downloads code from other people. Learn a handful of Cargo commands now and you’ll have the everyday workflow that carries you through the whole rest of Rust. This is the practical center of your day-to-day.

The idea, slowly

You could compile Rust by calling rustc on a single file by hand. Almost nobody does. Real projects have many files, need outside libraries, want tests, and have to be built the same way on everyone’s machine. Cargo handles all of that so you don’t have to think about it. One tool, one set of commands, every project the same shape.

Making a new project

To start a project:

cargo new hello-rust
cd hello-rust

cargo new hello-rust builds a folder named hello-rust with everything a Rust project needs already in place. Here’s what it “thinks” it should give you:

hello-rust/
├── Cargo.toml       <- the project's settings and dependency list
└── src/
    └── main.rs      <- your code starts here
  • src/main.rs is where your code lives. Cargo even fills it with a working “Hello, world” so the project runs immediately.
  • Cargo.toml is the project’s ID card and shopping list. It holds the project’s name, its version, and — importantly — the list of outside libraries it depends on. (.toml is just a simple settings-file format; don’t worry about it beyond “this is where project settings live.”)

Building and running

From inside the project folder, the command you’ll use constantly:

cargo run

cargo run does two jobs in one: it builds your code (compiles it) and then runs the resulting program. On a brand-new project it prints Hello, world!. That single line main.rs looks like this:

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

If you only want to compile without running, use cargo build. If you only want to check that the code is valid without producing a finished program — which is faster — use cargo check. That last one is a beginner’s best friend: while you’re fixing compiler errors, cargo check gives you the same errors much quicker than a full build.

Debug builds vs release builds

By default Cargo builds a debug version: it compiles fast and keeps extra info to help you find bugs, but the program itself runs slower. When you want the fast, optimized version, add --release:

cargo build --release

For learning, plain cargo run (debug) is exactly what you want. Reach for --release only when you actually care about the program’s speed.

Adding someone else’s code (a dependency)

Rust’s real power shows up when you pull in libraries — called crates — that other people wrote. Say you want colored terminal text. From your project folder:

cargo add colored

cargo add writes a line into your Cargo.toml under [dependencies], and the next cargo run downloads and compiles that crate for you automatically. You can also edit Cargo.toml by hand; cargo add just does it for you safely. The huge public collection of crates lives at crates.io.

The lockfile: Cargo.lock

The first time you build, Cargo creates a file called Cargo.lock. It records the exact versions of every dependency it used. Its whole purpose is repeatability: with the lockfile, your project builds with the identical library versions on your laptop, your friend’s laptop, and a server — no “works on my machine” surprises. For an application (a program you run), commit Cargo.lock to git. You don’t edit it by hand; Cargo manages it.

The commands you’ll actually use daily

  • cargo new <name> — start a project.
  • cargo run — build and run it (your most-used command).
  • cargo check — quickly verify it compiles, no finished program. Great while fixing errors.
  • cargo build — compile it (--release for the fast, optimized version).
  • cargo test — run your tests.
  • cargo add <crate> — add a dependency.

That’s the core loop. Everything else you can look up when you need it.

Common mistakes

  • Running Cargo from the wrong folder. Cargo commands work inside a project — the folder that has Cargo.toml. If you run cargo run and get an error about no manifest / Cargo.toml not found, you’re probably one folder too high. cd into the project first.
  • Forgetting to cd after cargo new. cargo new hello-rust makes the folder but leaves you outside it. You must cd hello-rust before cargo run does anything.
  • Reaching for cargo clean at the first weird error. cargo clean deletes all built files so the next build starts from scratch — slow, and rarely the actual fix. Stale-build problems are uncommon; read the real error first and only clean if you genuinely suspect leftover build junk.
  • Not committing Cargo.lock for an application. Leave it out and different machines may pull different dependency versions, causing bugs that only appear “over there.” Commit it for apps so everyone builds the same thing.

More examples

Starting a library instead of an app

You’re writing a chunk of logic — say, date-parsing helpers — that other code will import, not something you run directly.

cargo new --lib date_utils

Adding a dependency and building it

Your project needs to read JSON, so you pull in a crate instead of writing a parser yourself.

cargo add serde_json
cargo build

Running just one test by name

Your test suite has grown to two hundred tests, but you’re only working on one function right now and don’t want to wait for all of them every time.

cargo test parses_positive_numbers

Fast feedback with cargo check

You’re mid-refactor, chasing compiler errors one at a time, and producing a full runnable binary after every tiny edit is wasted work.

cargo check

Running one binary out of several

Your project grew a src/bin/ folder with a couple of small helper programs alongside the main app, and you want to run just one of them.

cargo run --bin date_utils_cli

Your turn

No code to debug this time — the exercise is to run the real workflow and read what Cargo prints. In your terminal, do exactly this:

cargo new hello-rust
cd hello-rust
cargo run

Then open src/main.rs, change the text inside println! to a message of your own, and run cargo run again. Watch how the output changes.

Show solution

The first cargo run compiles the starter project and prints:

   Compiling hello-rust v0.1.0 (/path/to/hello-rust)
    Finished dev [unoptimized + debuginfo] target(s) in 0.5s
     Running `target/debug/hello-rust`
Hello, world!

After you edit src/main.rs — say to println!("Cargo works!"); — running cargo run again recompiles just what changed and prints your new line, Cargo works!. If you saw the Compiling / Finished / Running lines and then your text, the whole toolchain is working end to end.

Quick check

Remember this

  • cargo new <name> starts a project; then cd into it before running anything.
  • cargo run builds and runs — it’s your most-used command.
  • cargo check is a fast way to catch errors without a full build; great while fixing them.
  • Dependencies (crates) are listed in Cargo.toml; add them with cargo add <crate> from crates.io.
  • Cargo.lock pins exact dependency versions for repeatable builds — commit it for applications.

Go deeper

Next:

Hello, world

Beginner · Start here

What & why

Every language starts with a program that prints one line. In Rust that program teaches you the three pieces you’ll type in every program you ever write: an entry point, a macro call, and a statement. Get comfortable with these now and the rest of Rust has a lot less to be scared of.

The idea, slowly

Here is the whole program:

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

Let’s read it the way the compiler does, one piece at a time.

  • fn main()fn means “I’m defining a function.” main is a special name: when you run a Rust program, it starts at main. Always. If main isn’t there, there’s nothing to run. Think of main as the front door of your program — the computer walks in through it.
  • () — the empty parentheses mean “this function takes no inputs.” Later your functions will take inputs and those go between the parentheses.
  • { ... } — the curly braces hold the body: the list of things to do. Everything between { and } runs top to bottom.
  • println!("Hello, world!") — this prints a line of text. println = “print line” (it adds a newline at the end for you). The text you want to print goes in the quotes.
  • The ! — this is the surprising one. println! has an exclamation mark because it’s a macro, not a normal function. You don’t need to know how macros work yet. For now just remember: if you see ! after a name, it’s a macro, and println! is the one you’ll use constantly. Forgetting the ! is the #1 beginner error here.
  • The ; — the semicolon ends the statement. It’s how you tell Rust “this instruction is finished, move to the next one.” Most lines inside { } end with ;.

That’s it. Front door (main), do one thing (println!), end the instruction (;).

Each println! is its own instruction, running in order:

fn main() {
    println!("Learning Rust.");
    println!("One line at a time.");
}

The curly braces {} inside the text are a placeholder — Rust fills them in:

fn main() {
    let day = 3;
    println!("Day {} of learning Rust", day);
    // You can also name the value directly inside the braces:
    let name = "Shamirul";
    println!("Keep going, {name}!");
}

Don’t worry about let yet (that’s the next lesson). Just notice that {} is where a value gets dropped into your text.

Common mistakes

  • Forgetting the !. println("hi") is wrong; println!("hi") is right. The compiler will say it can’t find a function named println — that’s your hint you dropped the !.
  • Forgetting the ;. Rust will complain it “expected ;”. Add it at the end of the line.
  • Mismatched quotes or braces. Every " needs a closing ", every { a closing }. The compiler points at the line where it got confused.

More examples

Printing to stderr for diagnostics

Real programs often split their output: the actual result goes to one stream, debug notes go to another, so a script piping your output doesn’t get polluted with noise.

fn main() {
    println!("Result: 42");
    eprintln!("[debug] computed via loop, took 3 steps");
}

A greeting with a hardcoded name

Setup scripts and installers love a little personal touch, even before you’ve learned how to take real input from a user.

fn main() {
    let user = "Alice";
    println!("Welcome aboard, {user}! Let's get you set up.");
}

Formatting a receipt line

A {} placeholder isn’t limited to printing a value back out untouched — you can drop in the result of a calculation too.

fn main() {
    let item = "coffee";
    let price = 4;
    let qty = 2;
    println!("{qty}x {item} = ${}", price * qty);
}

A startup banner

Command-line tools often print a little header before they get to work, so you know what’s running and that it actually started.

fn main() {
    println!("=================================");
    println!(" MyApp v1.0 -- starting up");
    println!("=================================");
}

A quick unit conversion

println! doubles as a fine one-off calculator display, long before you’ve learned functions or real user input.

fn main() {
    let celsius = 24;
    let fahrenheit = celsius * 9 / 5 + 32;
    println!("{celsius}C is {fahrenheit}F");
}

Your turn

This program is broken in two ways. Fix it so it prints Hello, Rust! on its own line. Press ▶ Run to check.

fn main() {
    println("Hello, Rust!")
}
Show solution

Two fixes: add the ! to make it the println! macro, and add the ; to end the statement.

fn main() {
    println!("Hello, Rust!");
}

Quick check

Remember this

  • Executables start at fn main() — it’s the front door.
  • ! means macro. println! is a macro, so it always has the !.
  • Statements end with ;.
  • {} inside a string is a placeholder that gets filled with a value.

Go deeper

Next:

Reading compiler errors

Beginner · Start here

What & why

Rust’s compiler says “no” a lot, especially while you’re learning — and its error messages look like a wall of text with arrows and codes. Here’s the secret: those messages are some of the most helpful in all of programming. They point at the exact spot, explain the problem in English, and often hand you the fix. Learning to read them calmly is the single highest-value skill for a beginner. Get this and you stop being stuck.

The idea, slowly

When Rust rejects your code it’s not scolding you. It caught a bug at your desk instead of letting it reach real users. Every error is the compiler saying: “I found a problem here, and here’s what I think is wrong.” Your job is just to read what it tells you, in order, without panicking.

The anatomy of an error message

Let’s take a small broken program. This one moves a value and then tries to use it again (don’t worry about why that’s illegal yet — this lesson is about reading the message, not ownership):

fn main() {
    let name = String::from("Rust");
    let other = name;             // value moves out of `name` here
    println!("{}", name);         // ...and we try to use `name` anyway
}

Rust refuses to build it and prints something like this:

error[E0382]: borrow of moved value: `name`
 --> src/main.rs:4:20
  |
2 |     let name = String::from("Rust");
  |         ---- move occurs because `name` has type `String`, which does not implement the `Copy` trait
3 |     let other = name;
  |                 ---- value moved here
4 |     println!("{}", name);
  |                    ^^^^ value borrowed here after move

That looks like a lot. It isn’t. Read it in five pieces, top to bottom:

  • error[E0382] — the headline. error means the build failed. E0382 is a searchable code; you can look it up (see below) for a fuller explanation.
  • **borrow of moved value: name`` ** — the problem in plain English. Something used name after its value had moved away.
  • --> src/main.rs:4:20 — the location. File src/main.rs, line 4, column 20. This is where Rust wants you to look first.
  • The | diagram — the compiler quotes your own code and draws under it. The underlines matter:
    • ---- marks related spots — here, where the value was created and where it moved.
    • ^^^^ points at the exact thing that’s wrong — here, the name you tried to use too late.
  • The notes — lines like “move occurs because name has type String, which does not implement the Copy trait.” This is the compiler explaining why, in words. It’s telling you String moves rather than copies.

Read like that, the message isn’t a wall — it’s a labeled diagram of exactly what happened.

Read the FIRST error first

When you have several errors, Rust prints them all. Start with the top one and fix only that. Often one real mistake — a missing bracket, a wrong name — confuses the compiler and produces a cascade of follow-on errors that aren’t really separate problems. Fix the first, rebuild, and watch a pile of the others vanish. Chasing the last error first usually wastes your time.

When the compiler hands you the fix

Rust frequently suggests an actual repair, marked help:. For example, a missing ! on println:

error[E0423]: expected function, found macro `println`
 --> src/main.rs:2:5
  |
2 |     println("hi");
  |     ^^^^^^^ not a function
  |
help: use `!` to invoke the macro
  |
2 |     println!("hi");
  |            +

See that help: line and the + showing where to add the !? When Rust suggests a change, try it first, before you start guessing. It’s right far more often than not. The fixed program compiles:

fn main() {
    println!("hi");
}

warning is not error

You’ll also see yellow warning messages. A warning means “this compiled and will run, but something looks off” — like a variable you created and never used. Your program still works. Warnings are worth cleaning up, but they won’t stop you. Only error blocks the build. Don’t confuse “I have warnings” with “it’s broken.”

Look up a code when you’re stuck

Every error[EXXXX] code is documented. In the terminal:

rustc --explain E0382

That prints a longer, example-filled explanation of that specific error. There’s also an online error index. Use these when the inline message isn’t enough — you’re not the first person to hit that code.

Common mistakes

  • Panicking at the size of the message and not reading it. The text looks dense, so beginners skim or give up. But it’s structured — headline, location, diagram, why. Read it slowly, top to bottom, and it’s usually clear. The message almost always contains your answer.
  • Fixing the last error first. One early mistake often spawns several later errors. If you fix the bottom one, you may be “fixing” a symptom of something above it. Always start at the first error and rebuild.
  • Ignoring the help: suggestion and guessing instead. Rust’s suggested fix is right most of the time. Trying random changes before reading help: turns a 10-second fix into a 10-minute fight.
  • Treating warnings like errors (or ignoring them forever). A warning still compiles and runs; don’t think your program is broken because you see yellow. But don’t let them pile up unread either — some warnings are pointing at real mistakes.
  • Never using rustc --explain. When an inline message baffles you, the code has a fuller write-up one command away. Beginners forget it exists.

More examples

A type mismatch

You read a value from somewhere text-based — a web form, a config file, a CLI flag — and forget it arrives as text, not a number. Rust’s error[E0308]: mismatched types names the exact line and says plainly which type it expected.

fn main() {
    let count: i32 = "5"; // ERROR: expected `i32`, found `&str`
    println!("count = {count}");
}

A missing semicolon that blames the next line

Drop a ; after a let, and the error often points at the line after your mistake instead of the mistake itself — expected ;, found keyword let, aimed at line 3 even though line 2 is where the semicolon is missing.

fn main() {
    let price = 12
    let tax = 2;
    let total = price + tax;
    println!("total = {total}");
}

A borrow-checker error

You grab a reference into a vector, then try to grow the vector while that reference is still alive — something a language like C++ would quietly let you do, and quietly corrupt later.

fn main() {
    let mut scores = vec![1, 2, 3];
    let first = &scores[0];
    scores.push(4); // ERROR: cannot borrow `scores` as mutable...
    println!("{first}");
}

Wrong number of arguments

You add a parameter to a function mid-refactor and forget to update a call site somewhere else in the file — error[E0061] names the function and even suggests where to add the missing argument.

fn greet(name: &str, times: u32) {
    for _ in 0..times {
        println!("Hello, {name}!");
    }
}

fn main() {
    greet("Sam"); // ERROR: this function takes 2 arguments but 1 was supplied
}

An unused variable — a warning, not an error

Quick prototyping often leaves a variable you set up but never got around to using — the program still compiles and runs, it just gets a friendly nudge.

fn main() {
    let total = 42; // never used below -- Rust warns, but still runs
    println!("Program finished.");
}

Your turn

This program is broken, and if you press Run the compiler will complain. Read its message first — find the location it points to and the ^^^^ underline — then fix the code so it prints Score: 10.

fn main() {
    let score = 10
    println("Score: {}", score)
}
Show solution

The compiler points at two things: a missing ; after let score = 10, and println used as a function instead of the println! macro (plus its own missing ;). Read top to bottom, fix the first error, and the rest fall into place:

fn main() {
    let score = 10;
    println!("Score: {}", score);
}

Notice how the fixes were exactly what the message described — the ; it “expected” and the ! its help: line suggested.

Quick check

Remember this

  • Error messages are structured: code → plain-English problem → location → code diagram → why. Read them top to bottom.
  • The --> line is the location (file:line:column); ^^^^ points at the exact culprit.
  • Fix the first error first, then rebuild — many later errors disappear on their own.
  • When you see a help: suggestion, try it before guessing; it’s usually right.
  • A warning still compiles and runs; only error stops the build.
  • Stuck on a code? Run rustc --explain E0382 (with your code) for a fuller explanation.

Go deeper

Next:

Variables and mutability

Beginner · Language basics

What & why

A variable is a name you give to a value so you can use it later. In most languages a variable can be changed whenever you like. Rust flips that default: once you name a value, it’s locked unless you explicitly ask for permission to change it. This one habit prevents a huge class of “wait, who changed this?” bugs, so it’s worth understanding early.

The idea, slowly

Naming a value with let

You create a variable with the keyword let:

fn main() {
    let name = "Rust";
    println!("Learning {name}");
}

Read let name = "Rust"; as “let the name name stand for the text Rust.” From now on, wherever you write name, Rust reads "Rust".

Notice you didn’t tell Rust that name is text. Rust figured it out from the value on the right. This is called type inference — the compiler is quietly thinking “the right-hand side is text, so name is text.” You’ll learn to add types by hand in the next lesson; for now, let Rust guess.

The surprise: variables don’t change by default

Try to change a variable and Rust stops you:

fn main() {
    let count = 1;
    count = 2;          // ERROR: cannot assign twice to immutable variable
    println!("{count}");
}

Press Run and read the error: “cannot assign twice to immutable variable count.” Immutable just means “cannot be changed.” By default, every let you write is immutable.

Why would a language do this? Think of it like writing a value in ink instead of pencil. If you know a value can never change, you can trust it everywhere. The compiler is on your side here: it’s saying “you told me count was 1, and now you’re changing it — did you mean to?”

Asking permission with mut

When you do want a value to change, add the word mut (short for “mutable,” meaning “changeable”):

fn main() {
    let mut count = 1;   // mut = "I plan to change this"
    count += 1;          // now allowed
    count += 1;
    println!("count is {count}"); // 3
}

let mut count tells both Rust and the next human who reads your code: “keep an eye on this one, it moves.” That little mut is a promise you make on purpose.

Shadowing: a new variable wearing the same name

There’s a second thing that looks like changing a value but isn’t. You can write let twice with the same name:

fn main() {
    let name = "Rust";
    let name = name.len();   // a brand-new variable, also called `name`
    println!("the name has {name} letters"); // 4
}

This is called shadowing. The second let name does not change the first one. It creates a completely new variable that happens to reuse the name name. The old one is still there underneath, just hidden (shadowed) — like a new sticky note stuck over an old one.

Here’s the part beginners love: because it’s a brand-new variable, its type can be different. The first name was text; the second name is a number (the length). You could never do that with mut, because mut changes a value in place and the type must stay the same.

So there are two different ideas:

  • mut — same variable, value changes, type stays the same.
  • Shadowing — new variable reusing the name, type may change.

Use mut when you’re genuinely updating one thing over time (a counter, a running total). Use shadowing when you want to transform a value into a new form and don’t need the old one anymore.

Constants (a quick mention)

If you have a value that’s fixed forever and known at compile time, you can use const instead of let. Constants are always written in SCREAMING_SNAKE_CASE and need a type:

const MAX_TRIES: u32 = 3;

fn main() {
    println!("You get {MAX_TRIES} tries");
}

You don’t need const often as a beginner. Just recognize it when you see it: “a name for a value that never, ever changes.”

Common mistakes

  • Trying to reassign without mut. let x = 1; x = 2; fails with “cannot assign twice to immutable variable.” The fix is let mut x = 1;. This is the single most common early error, and the compiler even suggests adding mut.
  • Thinking shadowing mutates the old value. It doesn’t. let x = 5; let x = x + 1; makes a new x. If you expected to see the old value somewhere else, you’ll be confused — nothing changed the first one, it’s just hidden.
  • Adding mut you never use. If you write let mut x = 5; but never change x, Rust warns: “variable does not need to be mutable.” Drop the mut. It’s a hint that your intent and your code disagree.
  • Confusing const and let. const needs an uppercase name and an explicit type, and can’t use mut. If you try const x = 5; you’ll get an error asking for the type.

More examples

Running total for a shopping cart

Ringing up items one at a time means the total genuinely changes as you go — a textbook job for mut.

fn main() {
    let mut cart_total = 0.0;
    cart_total += 12.99;
    cart_total += 4.50;
    cart_total += 7.25;
    println!("cart total: ${cart_total:.2}");
}

Shadowing to convert units step by step

User input arrives as text, but you need a number, and then you need it in a different unit. Shadowing lets you reuse one name as the value transforms.

fn main() {
    let temp = "98.6"; // raw input, as text
    let temp: f64 = temp.parse().unwrap(); // now a number
    let temp = (temp - 32.0) * 5.0 / 9.0;  // now Celsius
    println!("{temp:.1}C");
}

A const for app-wide config

Things like a retry limit or a max file size don’t change while the program runs, and every function that needs them should agree on the same value — that’s what const is for.

const MAX_LOGIN_ATTEMPTS: u32 = 3;

fn main() {
    let mut attempts = 0;
    while attempts < MAX_LOGIN_ATTEMPTS {
        attempts += 1;
        println!("attempt {attempts} of {MAX_LOGIN_ATTEMPTS}");
    }
    println!("locked out");
}

Why swapping needs a temporary variable

If you write a = b; b = a;, the first line already overwrote a, so the second line just copies b back into itself. You need somewhere to stash the original value first.

fn main() {
    let mut a = "left";
    let mut b = "right";

    let temp = a;   // hold a's value before it's overwritten
    a = b;
    b = temp;

    println!("a={a}, b={b}");
}

Shadowing only lasts inside its block

A shadowed name doesn’t leak out of the { } it was created in — once the block ends, the outer variable is back, untouched.

fn main() {
    let status = "pending";
    {
        let status = "approved"; // only shadows inside this block
        println!("inside: {status}");
    }
    println!("outside: {status}"); // original still stands
}

Your turn

This program wants to count up to 3, then relabel the result as a message. It’s broken in two places. Fix it so it prints the count going 1, 2, 3 and then a final line. Press ▶ Run.

fn main() {
    let total = 1;
    total += 1;
    total += 1;
    println!("total is {total}");

    let total = format!("final total: {total}");
    println!("{total}");
}
Show solution

The counter needs mut because we change it in place. The second let total is fine — that’s shadowing, turning the number into a text message, which is allowed.

fn main() {
    let mut total = 1;
    total += 1;
    total += 1;
    println!("total is {total}");

    let total = format!("final total: {total}");
    println!("{total}");
}

The only real bug was the missing mut. The re-let at the bottom was already correct shadowing.

Quick check

Remember this

  • let names a value; by default that value is immutable (can’t be changed).
  • Add mut when you genuinely need to reassign: let mut x = ....
  • Shadowing (let x twice) makes a new variable with the same name — it can even change the type.
  • mut = same variable changes in place; shadowing = new variable, old one hidden.
  • Mutability is a deliberate choice you announce, not a free-for-all default.

Go deeper

Next:

Data types

Beginner · Language basics

What & why

Every value in Rust has a type — a label that says what kind of thing it is: a whole number, a decimal, a true/false, a piece of text. Rust cares deeply about types because knowing the shape of your data up front is how it catches mistakes before your program runs. The good news: most of the time Rust guesses the type for you, and you only spell it out when it asks.

The idea, slowly

Two big families: scalars and compounds

There are two families of built-in types:

  • Scalar types hold one value: a number, a true/false, a single character.
  • Compound types bundle several values together: tuples and arrays.

Let’s meet them slowly.

Numbers: integers and floats

An integer is a whole number — no decimal point. 5, -3, 1000. A float is a number with a decimal point — 3.14, -0.5.

Rust integers come with a size and a sign baked into the type name. Don’t panic, it’s a simple code:

  • The letter i means signed (can be negative). The letter u means unsigned (zero or positive only).
  • The number is how many bits it uses, which decides how big it can get: 8, 16, 32, 64.

So i32 is a signed 32-bit integer (the everyday default), u8 is an unsigned 8-bit integer (0 to 255), u64 is a big positive-only number, and so on.

fn main() {
    let age: u32 = 32;        // unsigned, can't be negative
    let temperature: i32 = -5; // signed, can be negative
    let pi = 3.14;            // no annotation → Rust picks f64 (a float)
    println!("{age}, {temperature}, {pi}");
}

If you don’t say which integer type you want, Rust defaults to i32. For decimals it defaults to f64. So you rarely need to write the type — let x = 5; just works and x is an i32.

Booleans and characters

A boolean (bool) is either true or false. That’s the whole type. It’s what if looks at.

A character (char) is a single letter, digit, or symbol, written in single quotes. Note: single quotes for one character, double quotes for text.

fn main() {
    let is_ready: bool = true;
    let grade: char = 'A';        // single quotes = one char
    let heart = '♥';              // even emoji-like symbols work
    println!("{is_ready}, {grade}, {heart}");
}

Tuples: a fixed group of possibly-different types

A tuple groups a fixed number of values, which can be different types, inside parentheses. Think of it as a tiny labeled box with a set number of slots.

fn main() {
    let point: (i32, i32) = (8, 13);
    let mixed = (500, 6.4, 'x');   // an integer, a float, a char

    // Get values out by position, starting at 0:
    println!("x is {}", point.0);
    println!("y is {}", point.1);

    // Or unpack all at once ("destructuring"):
    let (a, b, c) = mixed;
    println!("{a}, {b}, {c}");
}

You reach into a tuple with a dot and the position number: .0 is the first slot, .1 the second. A tuple’s size is fixed forever — a 2-tuple can never become a 3-tuple.

Arrays: many of the same type, fixed length

An array holds several values of the same type, and its length is fixed. It’s written in square brackets.

fn main() {
    let names = ["a", "b", "c"];        // 3 text values
    let zeros = [0; 5];                 // shorthand: five 0s → [0, 0, 0, 0, 0]

    println!("first: {}", names[0]);    // index into it with [ ]
    println!("how many: {}", names.len());
}

Two things to remember: every element must be the same type, and the length can’t grow or shrink. If you need a list that grows, you want a vector (Vec), which lives in the Collections lesson. Rule of thumb: fixed, known number of items → array; changing number of items → vector.

Type inference, and when you must help

Most of the time Rust reads the value and figures out the type. But sometimes it genuinely can’t decide and asks you to say. The classic case is parsing text into a number:

fn main() {
    let text = "42";
    let number: u32 = text.parse().unwrap();  // the : u32 tells parse what to make
    println!("{}", number + 1);
}

Without : u32, Rust wouldn’t know which number type to parse into, and would refuse to guess. The annotation is you stepping in to answer its question.

Common mistakes

  • Mixing number types without converting. Rust won’t quietly add an i32 to a u8 for you. let a: i32 = 1; let b: u8 = 2; a + b fails with a “mismatched types” error. You must convert one, e.g. a + b as i32. Rust never does surprise conversions.
  • Using double quotes for a char. 'A' is a char; "A" is text (a string). Swapping them gives a type error. Single quote = one character.
  • Indexing past the end of an array. let a = [1, 2, 3]; a[5] compiles but panics (crashes) at runtime with “index out of bounds.” Arrays don’t stretch — the index must be within the length.
  • Expecting an array to grow. Arrays are fixed length. If you try to “add” an item, there’s no method for it. Reach for a Vec instead when the size changes.
  • Overflowing a small integer. A u8 maxes out at 255. Going past it panics in debug builds. Pick a type big enough for your values.

More examples

A tuple as a function’s multi-value return

When a function needs to hand back more than one related value — like an x/y offset — a tuple is the simplest way, no extra type required.

fn origin_offset() -> (i32, i32) {
    (3, -7)
}

fn main() {
    let (dx, dy) = origin_offset();
    println!("move right {dx}, up {dy}");
}

A fixed-size array of days

There are always exactly seven days in a week, so an array — not a growable list — is the right shape for this data.

fn main() {
    let days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
    println!("day 3 is {}", days[2]);
    println!("week has {} days", days.len());
}

Combining u32 and i32 on purpose

Inventory counts are naturally unsigned, but a “returns” ledger can go negative. Mixing them means converting one side explicitly so both operands agree.

fn main() {
    let inventory: u32 = 40;
    let sold: i32 = -5; // sales returns can go negative in the ledger

    let total = inventory as i32 + sold; // convert explicitly, types must match
    println!("remaining: {total}");
}

A float precision surprise

Floats can’t represent every decimal exactly, so simple-looking arithmetic sometimes prints more digits than you expect. Rounding for display fixes the look, not the underlying value.

fn main() {
    let price = 0.1 + 0.2;
    println!("{price}");        // 0.30000000000000004
    println!("{:.2}", price);   // rounded for display: 0.30
}

A fixed array for pixel bytes

An image pixel always has exactly three color channels, and each one fits in a single byte — a perfect match for a small, same-typed, fixed-length array.

fn main() {
    let pixel: [u8; 3] = [255, 87, 51]; // R, G, B
    println!("red={}, green={}, blue={}", pixel[0], pixel[1], pixel[2]);
}

Your turn

This program mixes up its types in three places. Fix it so it compiles and prints the point and grade. Press ▶ Run.

fn main() {
    let grade: char = "A";
    let point: (i32, i32) = (8, 13, 21);
    let scores = [90, 85, "seventy"];

    println!("grade {grade}, point ({}, {})", point.0, point.1);
    println!("first score {}", scores[0]);
}
Show solution

Three type errors: grade needs single quotes to be a char; the tuple’s type says two slots but the value has three; and the array mixes numbers with text.

fn main() {
    let grade: char = 'A';
    let point: (i32, i32) = (8, 13);
    let scores = [90, 85, 70];

    println!("grade {grade}, point ({}, {})", point.0, point.1);
    println!("first score {}", scores[0]);
}

Every value now matches the type it claims to be.

Quick check

Remember this

  • Integers name their sign and size: i32 (signed) is the default, u32 is unsigned, u8/u64 are smaller/bigger.
  • Decimals are floats; f64 is the default. Booleans are true/false. A char is one character in single quotes.
  • Tuples group a fixed number of possibly-different types; reach in with .0, .1.
  • Arrays hold many values of the same type at a fixed length; a growing list is a Vec instead.
  • Rust infers types when it can, but you must annotate when it genuinely can’t decide (like parse).

Go deeper

Next:

Type conversion and casting

Beginner · Language basics

What & why

Rust never converts types behind your back. No language-level “42” + 1 silently becoming 43, no integer quietly widening into a float mid-expression. Every conversion is a decision you write down, and Rust gives you four different tools for four different situations: as for “just reinterpret these bits, I know what I’m doing,” From/Into for conversions that always succeed, TryFrom/TryInto for conversions that might fail, and FromStr for turning text into a typed value. Picking the right one is something you’ll do constantly, so it’s worth understanding what each one actually promises.

The idea, slowly

as: the no-questions-asked cast

as is a manual cast between primitive types. It always compiles, it never panics, and it never checks whether the result “makes sense” — it just reinterprets the bits according to fixed rules. Think of it like pouring water from a big jug into a small cup: Rust won’t stop you, it’ll just let the extra spill out.

fn main() {
    let big: i64 = 300;
    let small = big as u8; // u8 only holds 0..=255
    println!("{small}"); // 44
}

300 in binary is 1 0010 1100. A u8 only keeps the low 8 bits: 0010 1100, which is 44. That’s what “truncation” means for as — it’s not rounding or clamping, it’s chopping off the bits that don’t fit.

Casting a negative signed number to an unsigned type follows the same “keep the bit pattern” rule, which can be surprising:

fn main() {
    let n: i32 = -1;
    let u = n as u8;
    println!("{u}"); // 255
}

-1i32 is stored as all 1-bits (two’s complement). Keeping the low 8 bits gives 1111 1111, which as an unsigned u8 is 255 — not 0, not an error.

What the compiler is thinking: as is a promise from you, not a proof. The compiler checks that the cast is legal (you can’t as-cast a String to an i32), but it does zero checking on whether the value fits. That’s the whole point — it’s the fast, unchecked path.

Casting between integer widths

Going from a smaller type to a bigger one (widening) always preserves the value — for signed integers the sign bit is extended, for unsigned integers the top is filled with zeros. Going from bigger to smaller (narrowing) keeps only the low bits and can change the value entirely.

fn main() {
    let small: i16 = -5;
    let widened = small as i64; // sign-extended: still -5
    println!("{widened}");

    let n: i64 = 70_000;
    let narrowed = n as i16; // i16 only holds -32768..=32767
    println!("{narrowed}"); // 4464 — not 70000, and not an error
}

Float-to-int casts: truncation, and saturation at the edges

Casting a float to an integer with as truncates toward zero (it drops the fractional part, it doesn’t round):

fn main() {
    let x = 3.9_f64;
    println!("{}", x as i32); // 3, not 4

    let neg = -3.9_f64;
    println!("{}", neg as i32); // -3, not -4
}

What happens when the float is way out of range for the target integer? Rust saturates instead of producing garbage — the result clamps to the target type’s min or max:

fn main() {
    let huge = 1e20_f64;
    println!("{}", huge as i32); // i32::MAX (2147483647)

    let tiny = -1e20_f64;
    println!("{}", tiny as i32); // i32::MIN
}

From and Into: conversions that always succeed

From/Into are for conversions between richer types that can never fail — every input has a valid output. You implement From, and Rust hands you Into automatically: the standard library has a blanket implementation impl<T, U> Into<U> for T where U: From<T>, so writing one trait gives you both directions of API.

struct Feet(f64);
struct Meters(f64);

impl From<Feet> for Meters {
    fn from(f: Feet) -> Meters {
        Meters(f.0 * 0.3048)
    }
}

fn main() {
    let f = Feet(10.0);
    let m: Meters = Meters::from(f); // explicit direction
    println!("{:.2}", m.0);

    let f2 = Feet(10.0);
    let m2: Meters = f2.into(); // same conversion, via the free Into
    println!("{:.2}", m2.0);
}

Only implement From when the conversion is total — every possible Feet value must become a valid Meters value. If some inputs can’t be converted, From is the wrong trait.

TryFrom and TryInto: conversions that can fail

When a conversion might not be possible — like fitting a big number into a small integer type — reach for TryFrom/TryInto, which return a Result instead of an unconditional value:

use std::convert::TryFrom;

fn main() {
    let ok: i32 = 200;
    let too_big: i32 = 300;

    println!("{:?}", u8::try_from(ok));      // Ok(200)
    println!("{:?}", u8::try_from(too_big)); // Err(...) — 300 doesn't fit in a u8
}

Unlike as, u8::try_from(300) doesn’t silently wrap to 44 — it tells you the conversion was impossible and lets you decide what to do about it. This is the tool to reach for whenever an out-of-range value represents a real bug you want to catch, not a value you’re happy to truncate.

Parsing text with FromStr

Turning a string into a number (or any type that implements it) goes through the FromStr trait, called via .parse():

fn main() {
    let good: Result<i32, _> = "42".parse();
    let bad: Result<i32, _> = "abc".parse();

    println!("{good:?}"); // Ok(42)
    println!("{bad:?}");  // Err(ParseIntError { .. })

    let n: i32 = "42".parse().unwrap(); // target type inferred from the `let`
    println!("{n}");

    let m = "42".parse::<i32>().unwrap(); // or spell it out with turbofish
    println!("{m}");
}

.parse::<i32>() works because i32 implements FromStr.parse() is really just calling FromStr::from_str for you. Since parsing text can always fail (the text might not be a valid number), it returns a Result, exactly like TryFrom.

Common mistakes

  • Using as to shrink a value and being surprised later. as never panics and never warns — it truncates or wraps silently. If out-of-range input would be a bug, use TryFrom/TryInto instead.
  • Assuming as clamps negative numbers to zero when casting to an unsigned type. It doesn’t — it reinterprets the bit pattern, so -1i32 as u8 is 255, not 0.
  • Implementing From for a conversion that can actually fail. From promises the conversion always succeeds. If some inputs are invalid, implement TryFrom and return Err for them.
  • Calling .unwrap() on .parse() or try_from() with untrusted input (user input, file contents, network data). That crashes the whole program on the first bad value instead of handling the error.

More examples

Parsing a CLI-style argument

Command-line arguments always arrive as text, even when they represent a number, so parsing with a fallback is the everyday pattern for reading them safely.

fn main() {
    let args = ["quantity", "12"];
    let quantity: i32 = args[1].parse().unwrap_or(0);
    println!("ordering {quantity} units");
}

Safely fitting a value into a pixel channel

A computed brightness value might come out too large for a single color channel, so TryFrom lets you check instead of silently corrupting the color.

use std::convert::TryFrom;

fn main() {
    let brightness: i32 = 240;
    match u8::try_from(brightness) {
        Ok(channel) => println!("pixel channel: {channel}"),
        Err(_) => println!("value doesn't fit in a byte"),
    }
}

Truncating a request ID into a small bucket

Hashing or bucketing schemes sometimes want the wraparound behavior of as — you’re deliberately keeping only the low bits to spread values across a fixed number of buckets.

fn main() {
    let request_id: u32 = 4_294_967_290; // near u32::MAX
    let bucket = request_id as u8; // wraps around, keeps only low 8 bits
    println!("bucket: {bucket}");
}

Turning an enum into its numeric score

Enums can carry an explicit numeric value, and as reads it back out — handy for things like priority levels you want to compare or sort.

enum Priority {
    Low = 1,
    Medium = 5,
    High = 10,
}

fn main() {
    let level = Priority::Medium as i32;
    println!("priority score: {level}");
}

From for a total, always-valid unit conversion

Minutes-to-seconds can never fail — every minute count has a valid seconds count — so this is exactly the kind of conversion From is meant for.

struct Seconds(u32);

impl From<u32> for Seconds {
    fn from(minutes: u32) -> Seconds {
        Seconds(minutes * 60)
    }
}

fn main() {
    let duration: Seconds = 5.into(); // 5 minutes -> seconds
    println!("{} seconds", duration.0);
}

Your turn

This function is supposed to clamp an i32 down into a u8, returning 0 for anything out of range. It doesn’t compile.

use std::convert::TryFrom;

fn to_byte(n: i32) -> u8 {
    u8::try_from(n) // forgot to handle the Result
}

fn main() {
    println!("{}", to_byte(50));
    println!("{}", to_byte(300));
}
Show solution

u8::try_from(n) returns Result<u8, TryFromIntError>, but to_byte promises to return a plain u8. The fix is to actually handle both outcomes of the Result:

use std::convert::TryFrom;

fn to_byte(n: i32) -> u8 {
    match u8::try_from(n) {
        Ok(b) => b,
        Err(_) => 0, // out of range: fall back instead of crashing
    }
}

fn main() {
    println!("{}", to_byte(50));  // 50
    println!("{}", to_byte(300)); // 0
}

try_from hands back a Result precisely because the conversion can fail — the compiler won’t let you treat that Result as if it were the value itself. match (or .unwrap_or(0), or ? in a function that itself returns Result) is how you unpack it.

Quick check

Remember this

  • as casts numbers by keeping/extending bits — it always compiles, never panics, and can silently truncate or wrap.
  • Widening (small type → big type) always preserves the value; narrowing (big type → small type) can change it.
  • Float-to-int as casts truncate toward zero, and saturate to the target’s min/max instead of producing garbage for out-of-range floats.
  • From/Into are for conversions that always succeed — implement From, and Into comes for free.
  • TryFrom/TryInto return Result for conversions that can fail — reach for these when an out-of-range value is a real bug.
  • "text".parse::<T>() converts a string into T via FromStr, returning Result<T, T::Err>.

Go deeper

Next:

Formatting with format!

Beginner · Language basics

What & why

Almost every program needs to turn data into text — a log line, an error message, a report. Rust doesn’t make you glue strings together with +; instead println!, format!, write!, and eprintln! all share one formatting mini-language, written inside {}. Learn that language once and you can print, build strings, log to stderr, and write into files with the same syntax.

The idea, slowly

The four macros: where the text goes

  • println! — prints to stdout, with a trailing newline.
  • print! — prints to stdout, no newline.
  • eprintln! / eprint! — same, but to stderr (the channel for errors/logs, kept separate from normal output).
  • format! — builds and returns a String instead of printing anything.
  • write! / writeln! — write formatted text into anything that implements std::fmt::Write (like a String) or std::io::Write (like a file), returning a Result you’re expected to handle.
use std::fmt::Write;

fn main() {
    let name = "Ferris";

    println!("Hello, {name}!"); // stdout + newline
    print!("no newline here ");  // stays on the same line
    println!("<- still here");

    eprintln!("this goes to stderr, not stdout"); // for errors/logs

    let s = format!("{name} says hi"); // builds a String, prints nothing
    println!("{s}");

    let mut buf = String::new();
    write!(buf, "{name} again").unwrap(); // write! returns a Result — must be handled
    println!("{buf}");
}

{} (Display) vs {:?} / {:#?} (Debug)

{} uses the Display trait — clean, user-facing output. {:?} uses Debug — a developer-facing dump of a value’s structure, and {:#?} is the same thing “pretty-printed” across multiple lines. Most built-in types implement both; your own types get Debug for free with #[derive(Debug)], but Display has to be written by hand (more on that below).

#[derive(Debug)]
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 1, y: 2 };
    println!("{p:?}");  // Point { x: 1, y: 2 }
    println!("{p:#?}"); // pretty-printed across multiple lines

    let v = vec![1, 2, 3];
    println!("{v:?}"); // [1, 2, 3]

    // println!("{p}"); // ERROR: `Point` doesn't implement Display
}

What the compiler is thinking: {:?} isn’t “print whatever you can figure out” — it’s a real trait bound. If the type doesn’t implement Debug, {p:?} fails to compile, not fails silently at runtime. That’s why #[derive(Debug)] shows up on almost every struct in real code: it’s cheap insurance for the day you need to inspect a value.

Positional, named, and captured arguments

fn main() {
    println!("{} scored {}", "Alice", 90);          // positional, implicit order
    println!("{0} scored {1}, {0} wins", "Bob", 88); // explicit index, reused

    let name = "Ferris";
    let score = 100;
    println!("{name} scored {score}");                 // captures variables directly
    println!("{n} scored {s}", n = name, s = score);   // named arguments
}

Captured identifiers ({name}) only work for plain variable names already in scope — not expressions or field access like {player.score}. For those you still pass the value as a regular argument: println!("{}", player.score).

Format strings are checked at compile time: reference an argument that doesn’t exist, or write invalid syntax inside {}, and the build fails right there — it never becomes a runtime surprise.

Width, precision, alignment, and fill

Inside the braces, after a :, you can control exactly how a value is padded:

fn main() {
    let value = 3.14159;
    println!("[{value:>8.2}]"); // right-align, width 8, 2 decimals: [    3.14]
    println!("[{value:<8.2}]"); // left-align:                      [3.14    ]
    println!("[{value:^8.2}]"); // center-align:                    [  3.14  ]

    let n = 7;
    println!("{n:03}"); // zero-padded to width 3: 007

    println!("[{:*>10}]", "hi"); // fill char '*', right-align, width 10: [********hi]

    let long = "Hello, world!";
    println!("{long:.5}"); // precision on a string truncates it: Hello
}

The pattern is {value:fill align width.precision}fill is the padding character (default space), align is < / > / ^ (left/right/center), width is the minimum total characters, and .precision means “decimal places” for floats but “max length” for strings.

Writing your own Display

Debug is mechanical and derived; Display is what you write by hand when you want your type to print the way an end user should see it:

use std::fmt;

struct Point {
    x: i32,
    y: i32,
}

impl fmt::Display for Point {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "({}, {})", self.x, self.y)
    }
}

fn main() {
    let p = Point { x: 3, y: 4 };
    println!("{p}"); // (3, 4) — via our Display impl
}

Inside fmt, you use write!(f, ...) — the same macro family, now writing into the formatter Rust gave you.

Common mistakes

  • Printing Debug output where a user will see it. {:?} is for developers debugging; implement Display for anything a real user reads.
  • Forgetting #[derive(Debug)]. {:?} on a type without it is a compile error (“the trait Debug is not implemented”), not a blank line at runtime.
  • Dropping the Result from write!. write!/writeln! can fail (writing to a file, for instance), so Rust warns on an unused Result — call .unwrap(), handle it with ?, or .expect(...).
  • Trying to format a field access or expression as a captured identifier, like {player.score} — only bare variable names can be captured; expressions must be passed as arguments.

More examples

Aligning a printed receipt

Left-aligning the item name and right-aligning the price inside a fixed width is what makes a loop of println! calls line up into neat columns instead of a ragged list.

fn main() {
    let items = [("Coffee", 4.50), ("Bagel", 3.25), ("Orange Juice", 2.75)];

    for (name, price) in items {
        println!("{name:<15}${price:>6.2}");
    }
}

Drawing a download progress bar

A fill character combined with left-alignment turns a plain string into a growing bar — the filled portion is real text, and the format spec pads the rest with - up to the target width.

fn main() {
    let percent = 65;
    let filled = "#".repeat((percent / 5) as usize);
    println!("[{:-<20}] {}%", filled, percent);
}

Printing a color as a CSS hex code

{:02X} formats a byte as two uppercase hex digits, zero-padded — string that together for red, green, and blue and you get exactly the #RRGGBB format a browser expects.

fn main() {
    let (r, g, b) = (255u8, 99u8, 71u8); // tomato red
    println!("#{:02X}{:02X}{:02X}", r, g, b);
}

A countdown timer’s own Display

Writing Display by hand lets Countdown decide it should always print as zero-padded MM:SS, no matter how the caller formats it — the conversion from raw seconds lives in one place.

use std::fmt;

struct Countdown {
    total_seconds: u32,
}

impl fmt::Display for Countdown {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        let minutes = self.total_seconds / 60;
        let seconds = self.total_seconds % 60;
        write!(f, "{:02}:{:02}", minutes, seconds)
    }
}

fn main() {
    let timer = Countdown { total_seconds: 125 };
    println!("time remaining: {}", timer);
}

Your turn

This program has two formatting mistakes. Find them before running it.

struct Player {
    name: String,
    score: u32,
}

fn main() {
    let p = Player { name: String::from("Ferris"), score: 42 };
    println!("{p}");
    println!("score: {p.score}");
}
Show solution

Player doesn’t implement Display, so {p} fails to compile. And {p.score} isn’t a valid captured identifier — capturing only works for plain variable names, not field access, so it’s an invalid format string on top of the missing Display impl.

use std::fmt;

struct Player {
    name: String,
    score: u32,
}

impl fmt::Display for Player {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "{} ({})", self.name, self.score)
    }
}

fn main() {
    let p = Player { name: String::from("Ferris"), score: 42 };
    println!("{p}");                // now uses our Display impl: Ferris (42)
    println!("score: {}", p.score); // field access passed as a normal argument
}

Implementing Display fixes the first println!; passing p.score as an ordinary positional argument (instead of trying to capture it) fixes the second.

Quick check

Remember this

  • println!/print! write to stdout, eprintln!/eprint! write to stderr, format! returns a String, write!/writeln! write into any fmt::Write/io::Write target.
  • {} uses Display (user-facing); {:?}/{:#?} use Debug (developer-facing, derivable).
  • Arguments can be positional ({}/{0}), named ({n = value}), or captured directly from a variable in scope ({name}) — but captures only work for plain identifiers, not expressions.
  • {value:fill align width.precision} controls padding: alignment (</>/^), minimum width, and decimal places (floats) or max length (strings).
  • Format strings are checked at compile time, so a typo in {} is a build failure, not a runtime bug.

Go deeper

Next:

Functions

Beginner · Language basics

What & why

A function is a named chunk of code you can run whenever you want, as many times as you want. Instead of copying the same ten lines everywhere, you put them in a function and call it by name. You’ve already met one function on day one: main. Now you’ll learn to write your own — to give them inputs and get answers back.

The idea, slowly

The shape of a function

Here’s a function that adds two numbers:

fn add(a: i32, b: i32) -> i32 {
    a + b
}

fn main() {
    let sum = add(2, 3);
    println!("sum is {sum}"); // 5
}

Let’s read the first line the way the compiler does:

  • fn addfn means “I’m defining a function,” and add is its name.
  • (a: i32, b: i32) — these are the parameters: the inputs. Each one needs a name and a type. a is an i32, b is an i32. Rust never guesses parameter types — you must always write them.
  • -> i32 — the arrow says “this function hands back an i32 when it’s done.” This is the return type. If a function returns nothing, you leave the arrow off entirely.
  • { a + b } — the body. This function’s whole job is to compute a + b.

Then in main, add(2, 3) calls the function: it runs add with a = 2 and b = 3, and the answer comes back and lands in sum.

The big idea: expressions vs statements

This is the part that trips up newcomers, so go slow. Rust code is made of two things:

  • A statement does something but produces no value. let x = 5; is a statement.
  • An expression evaluates to a value. 2 + 3 is an expression — it becomes 5.

Look again at the body of add:

fn add(a: i32, b: i32) -> i32 {
    a + b       // no semicolon! this is the return value
}

fn main() {
    println!("{}", add(10, 20));
}

The last line is a + b with no semicolon. In Rust, the final expression in a function body — written without a semicolon — is what the function returns. There’s no return keyword needed. The compiler thinks: “the last thing here is a value, and the function promised to return a value, so that’s the answer.”

Now watch what a single semicolon does:

fn add(a: i32, b: i32) -> i32 {
    a + b;      // ERROR: the ; throws the value away
}

fn main() {
    println!("{}", add(1, 2));
}

Press Run. The error says something like “mismatched types: expected i32, found ().” That () (called “unit”) means “nothing.” By adding ; you turned the expression a + b into a statement — you computed the sum and then threw it away. The function now returns nothing, but you promised an i32. A trailing semicolon is the number-one function bug in Rust.

You can use return too

The no-semicolon style is the idiomatic Rust way, but return also works and is required when you want to leave early:

fn describe(n: i32) -> &'static str {
    if n < 0 {
        return "negative"; // leave early
    }
    "zero or positive"      // last expression, no semicolon
}

fn main() {
    println!("{}", describe(-4));
    println!("{}", describe(7));
}

Notice both styles appear here: an early return (with a semicolon, because it’s a statement) and the final expression without one. Both are hand back a value.

Functions that return nothing

If a function just does something (like printing) and has no answer to give, skip the arrow:

fn greet(name: &str) {
    println!("Hello, {name}!");   // just does a thing; returns nothing
}

fn main() {
    greet("Shaon");
    greet("Rust");
}

&str is the type for a borrowed piece of text — you’ll see it constantly. For now just read it as “some text.”

Order doesn’t matter

Unlike some languages, you can call a function that’s defined below where you call it. Rust reads the whole file before deciding, so main can call add even if add is written afterward. Arrange your code however reads best.

Common mistakes

  • The trailing semicolon on the return value. fn f() -> i32 { x; } returns (), not x. The compiler says “expected i32, found ().” Remove the semicolon from the last line. This bites nearly everyone at first.
  • Forgetting parameter types. fn add(a, b) won’t compile. Every parameter needs a type: fn add(a: i32, b: i32). Rust never infers these.
  • Forgetting the return type. If your function hands back a value, you must declare it with -> Type. Without the arrow, Rust assumes the function returns nothing and complains when the body produces a value.
  • Mismatched return type. If you say -> i32 but the last expression is text, you get “mismatched types.” The declared type and the actual returned value must agree.
  • Adding ; after an if-expression you meant to return. fn f() -> i32 { if c { 1 } else { 2 }; } throws the value away. Drop the final ;.

More examples

A guard clause for a quick exit

When one condition makes the rest of the function pointless, return immediately instead of nesting everything else inside an else.

fn discount_price(price: f64, is_member: bool) -> f64 {
    if !is_member {
        return price; // guard clause: no discount, leave early
    }
    price * 0.9
}

fn main() {
    println!("{}", discount_price(100.0, false));
    println!("{}", discount_price(100.0, true));
}

Taking a slice instead of a Vec

Writing the parameter as &[i32] lets the function accept an array, a Vec, or any borrowed chunk of one — it doesn’t care how the caller stored the data.

fn average(scores: &[i32]) -> f64 {
    let sum: i32 = scores.iter().sum();
    sum as f64 / scores.len() as f64
}

fn main() {
    let quiz1 = [90, 85, 78];
    let quiz2 = vec![100, 95];
    println!("{:.1}", average(&quiz1)); // works on an array
    println!("{:.1}", average(&quiz2)); // and on a Vec
}

Returning several values as a tuple

Finding both the smallest and largest value in one pass means the function has two answers to hand back — a tuple lets it return them together.

fn min_max(values: &[i32]) -> (i32, i32) {
    let mut min = values[0];
    let mut max = values[0];
    for &v in values {
        if v < min { min = v; }
        if v > max { max = v; }
    }
    (min, max)
}

fn main() {
    let (lo, hi) = min_max(&[4, 9, 1, 7]);
    println!("low {lo}, high {hi}");
}

A recursive function

Factorial is naturally defined in terms of itself — 5! is 5 * 4! — so a function that calls itself is the most direct way to write it.

fn factorial(n: u64) -> u64 {
    if n == 0 {
        1
    } else {
        n * factorial(n - 1)
    }
}

fn main() {
    println!("5! = {}", factorial(5));
}

One function, reused everywhere the logic is needed

The moment you format a price in two places, you risk the two copies drifting apart. A function keeps the formatting rule in exactly one spot.

fn format_price(cents: u32) -> String {
    format!("${}.{:02}", cents / 100, cents % 100)
}

fn main() {
    let item = 1999;
    let tax = 160;
    println!("item: {}", format_price(item));
    println!("tax: {}", format_price(tax));
    println!("total: {}", format_price(item + tax));
}

Your turn

This function is meant to double a number and return it, but it doesn’t compile. Two things are wrong. Fix it. Press ▶ Run.

fn double(n) {
    n * 2;
}

fn main() {
    let result = double(21);
    println!("double is {result}");
}
Show solution

The parameter needs a type, the function needs a return type, and the last line must lose its semicolon so the value is actually returned.

fn double(n: i32) -> i32 {
    n * 2
}

fn main() {
    let result = double(21);
    println!("double is {result}"); // 42
}

n: i32 gives the input a type, -> i32 promises an answer, and removing the ; after n * 2 makes that the return value.

Quick check

Remember this

  • Define a function with fn name(params) -> ReturnType { body }.
  • Every parameter needs a type; Rust never guesses them.
  • The last expression with no semicolon is the return value — no return keyword needed.
  • Adding a ; to that last line throws the value away and returns () (nothing) — a very common error.
  • Use return to leave a function early; leave off -> Type when a function returns nothing.

Go deeper

Next:

Control flow

Beginner · Language basics

What & why

Control flow is how your program makes decisions and repeats work: “if this is true, do that,” “keep doing this until done,” “for each item in the list, handle it.” Every useful program branches and loops. Rust’s versions are familiar if you’ve seen another language, with a couple of strict rules that will save you from bugs.

The idea, slowly

if needs a real boolean

An if runs a block only when a condition is true:

fn main() {
    let score = 81;

    if score >= 70 {
        println!("pass");
    } else {
        println!("retry");
    }
}

Here’s Rust’s first rule that surprises people coming from C or JavaScript: the condition must be an actual bool. Rust does not treat 0 as false or 1 as true. This won’t compile:

fn main() {
    let count = 3;
    if count {                 // ERROR: expected `bool`, found integer
        println!("nonzero");
    }
}

The compiler is thinking: “you gave me a number, but if only understands true/false.” Write the comparison you actually mean: if count != 0 { ... }. This forces you to be explicit, which prevents the classic “I meant to compare but wrote an assignment” family of bugs.

You can chain more conditions with else if:

fn main() {
    let n = 0;
    if n > 0 {
        println!("positive");
    } else if n < 0 {
        println!("negative");
    } else {
        println!("zero");
    }
}

if is an expression — it produces a value

In Rust, if doesn’t just do things; it can give back a value. That means you can put an if on the right of a let:

fn main() {
    let score = 81;
    let grade = if score >= 70 { "pass" } else { "retry" };
    println!("{grade}");
}

Read it as: “let grade be \"pass\" if the score is high enough, otherwise \"retry\".” Each branch is a little expression, and the whole if becomes whichever branch runs. One catch: both branches must produce the same type (here, both text). If one arm gave text and the other a number, Rust couldn’t decide what grade is, and would error.

Three ways to loop

Rust has three looping tools. Learn what each is for.

loop repeats forever until you break out. It’s the most basic:

fn main() {
    let mut n = 0;
    loop {
        n += 1;
        if n == 3 {
            break;      // jump out of the loop
        }
    }
    println!("stopped at {n}"); // 3
}

A neat trick: loop can return a value by putting it after break:

fn main() {
    let mut n = 0;
    let result = loop {
        n += 1;
        if n * n > 20 {
            break n;        // hand this value out of the loop
        }
    };
    println!("first n whose square passes 20 is {result}"); // 5
}

while repeats as long as a condition stays true. Use it when you don’t know how many times up front:

fn main() {
    let mut countdown = 3;
    while countdown > 0 {
        println!("{countdown}...");
        countdown -= 1;
    }
    println!("liftoff!");
}

for walks through each item of a collection. This is the one you’ll use most:

fn main() {
    for item in [10, 20, 30] {
        println!("{item}");
    }
}

To repeat a fixed number of times, loop over a range with ..:

fn main() {
    for i in 1..4 {          // 1, 2, 3 — the end (4) is NOT included
        println!("count {i}");
    }
}

Watch the range carefully: 1..4 gives 1, 2, 3 — the right side is excluded. If you want to include it, write 1..=4 (with the =), which gives 1, 2, 3, 4.

Why prefer for over manual indexing

You could loop by hand with a counter and index into an array, but it’s easy to get the bounds wrong and crash with “index out of bounds.” for item in list can never run off the end, because Rust hands you each item directly. Reach for for first; it’s safer and reads better.

Common mistakes

  • Using a number as a condition. if count { } fails — Rust needs a bool. Write the comparison: if count != 0 { }. Same for while.
  • Mismatched if branch types. When an if produces a value, every branch must return the same type. let x = if c { 1 } else { "no" }; errors because one arm is a number and the other is text.
  • Off-by-one with ranges. 1..4 stops at 3, not 4. Forgetting the end is excluded leads to loops that run one time too few. Use 1..=4 when you want the last number included.
  • break outside a loop. break only works inside loop, while, or for. Using it elsewhere is an error.
  • Forgetting to change the while condition. If nothing inside the loop moves toward making the condition false, it runs forever. Make sure you update the variable the condition checks.

More examples

Searching until you find it, with break value

When you’re scanning for something, you often want the loop to hand back what it found, not just stop — break value does exactly that.

fn main() {
    let inventory = [3, 12, 47, 8, 47, 2];
    let mut i = 0;
    let position = loop {
        if inventory[i] == 47 {
            break i; // found it, hand back the index
        }
        i += 1;
    };
    println!("found at index {position}");
}

A labeled loop to escape two levels at once

Searching a grid means nesting a loop inside a loop. Once you find your target, a label lets you break out of both in one line instead of juggling a “found” flag.

fn main() {
    let grid = [[1, 2, 3], [4, 5, 6], [7, 8, 9]];
    let target = 5;

    'search: for row in grid {
        for cell in row {
            if cell == target {
                println!("found {target}!");
                break 'search; // stop both loops at once
            }
        }
    }
}

Draining a stack with while let

Popping items off a stack until it’s empty is a perfect fit for while let — it keeps going as long as pop() still hands back Some(value), and stops the instant it doesn’t.

fn main() {
    let mut undo_stack = vec!["type", "bold", "delete"];

    while let Some(action) = undo_stack.pop() {
        println!("undoing: {action}");
    }
    println!("nothing left to undo");
}

An if chain that picks a grade band

When there are more than two outcomes, chain else if and let the whole thing evaluate to a value — no separate variable needs reassigning afterward.

fn main() {
    let score = 84;

    let grade = if score >= 90 {
        "A"
    } else if score >= 80 {
        "B"
    } else if score >= 70 {
        "C"
    } else {
        "F"
    };
    println!("grade: {grade}");
}

Numbering items with enumerate

Printing a numbered list means pairing each item with its position. .iter().enumerate() hands you both at once, so you never have to track an index by hand.

fn main() {
    let playlist = ["Intro", "Chapter 1", "Chapter 2", "Outro"];

    for (i, track) in playlist.iter().enumerate() {
        println!("{}. {track}", i + 1);
    }
}

Your turn

This program should print pass or retry, then count 1, 2, 3. It has two problems. Fix it. Press ▶ Run.

fn main() {
    let score = 55;

    let grade = if score {
        "pass"
    } else {
        "retry"
    };
    println!("{grade}");

    for i in 1..3 {
        println!("count {i}");
    }
}
Show solution

The if condition must be a real comparison, not a bare number. And 1..3 only reaches 2 — to include 3 use the inclusive range 1..=3.

fn main() {
    let score = 55;

    let grade = if score >= 70 {
        "pass"
    } else {
        "retry"
    };
    println!("{grade}");

    for i in 1..=3 {
        println!("count {i}");
    }
}

score >= 70 gives a bool, and 1..=3 includes the final 3.

Quick check

Remember this

  • if/while conditions must be a real bool — Rust never treats numbers as true/false.
  • if is an expression: it can produce a value, and all branches must share one type.
  • loop runs forever until break (and break value can return a value); while runs while a condition holds; for walks a collection.
  • Ranges: 1..4 excludes the end (1,2,3); 1..=4 includes it (1,2,3,4).
  • Prefer for item in collection over manual indexing — it can’t run off the end.

Go deeper

Next:

Comments and documentation

Beginner · Language basics

What & why

Comments are notes in your code that the compiler ignores — they’re for humans. Rust has two flavors: ordinary comments for quick notes to yourself, and doc comments, a special kind that Rust can gather up and turn into a browsable website of your API. Knowing which is which saves you from writing useless comments and helps you write the useful kind.

The idea, slowly

Ordinary comments with //

Anything after // on a line is a comment. Rust skips it entirely:

fn main() {
    // This is a note to myself. The compiler ignores it.
    let tries = 3; // you can also put a comment at the end of a line
    println!("{tries}");
}

Use these for anything: reminders, explanations, a “TODO” for later. There’s no special meaning — it’s just text the compiler throws away.

For a longer note across several lines, either start each line with //, or use the block form /* ... */:

fn main() {
    /* This is a block comment.
       It can span multiple lines
       without a // on each one. */
    println!("hello");
}

Most Rust code uses // even for multiple lines, but both work.

The real skill: comment the why, not the what

Beginners often write comments that just repeat the code:

fn main() {
    let x = 5; // set x to 5   <- useless, the code already says this
    println!("{x}");
}

That comment adds nothing — anyone can see x is 5. A good comment explains something the code can’t say: why you did it, a tricky edge case, a reason that isn’t obvious. Think of comments as answering “why is this here?” not “what does this line do?”

Doc comments with ///

Now the special kind. A comment starting with three slashes /// is a doc comment. It’s not just a note — it documents the item written right below it (a function, struct, and so on). Rust’s tool cargo doc reads these and generates a real HTML documentation website.

/// Returns the name of the currently active profile.
///
/// This is the text shown in the app's title bar.
pub fn profile_name() -> &'static str {
    "stable"
}

fn main() {
    println!("{}", profile_name());
}

Notice the doc comment sits above the function, on its own lines, outside any function body. It describes profile_name. When you run cargo doc --open, Rust builds a page for profile_name with that text as its description. This is exactly how the official Rust standard library docs are made — every description you read there came from a /// comment in the source.

The pub keyword means “public” — this function is part of your library’s public interface. Doc comments are most valuable on pub items, because those are the parts other people will actually look up.

Doc comments can hold example code that gets tested

Here’s a small piece of Rust magic. Code inside a doc comment’s example block is run as a test when you run cargo test. So your examples can never silently go stale:

/// Doubles a number.
///
/// # Examples
///
/// ```
/// let answer = my_crate::double(21);
/// assert_eq!(answer, 42);
/// ```
pub fn double(n: i32) -> i32 {
    n * 2
}

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

You don’t need to understand this fully yet. Just tuck away the idea: documentation examples are real, tested code, which is why Rust’s docs are so trustworthy.

A quick summary of the marks

  • // — ordinary comment, for humans, one line.
  • /* ... */ — ordinary comment, block form, can span lines.
  • /// — doc comment, describes the item below it, feeds cargo doc.
  • //! — doc comment that describes the thing it’s inside (a whole module or file) rather than what’s below it. You’ll see this at the very top of files. It’s the same idea, aimed inward.

Common mistakes

  • Writing comments that repeat the code. let x = 5; // assign 5 to x wastes everyone’s time. Comment the reason, not the obvious mechanics.
  • Using // when you meant ///. A two-slash comment above a function is just a private note — it will not show up in cargo doc. If you want generated documentation, you need three slashes.
  • Putting a /// doc comment where there’s no item to document. A /// must sit directly above something it describes (a function, struct, etc.). Floating on its own with nothing below, or at the end of a line, it causes an error. For inner documentation use //! instead.
  • Letting comments drift out of date. A comment that describes old behavior is worse than none, because it misleads. When you change code, check the comment above it.
  • Over-commenting simple code. If a function is clear, it doesn’t need a paragraph. Save the words for the parts that genuinely need explaining.

More examples

A doc comment with a tested # Examples section

Showing a worked example right in the docs is one of the most useful things you can write — readers see real input and output without leaving the page.

/// Converts a Celsius temperature to Fahrenheit.
///
/// # Examples
///
/// ```
/// let f = temp::celsius_to_fahrenheit(0.0);
/// assert_eq!(f, 32.0);
/// ```
pub fn celsius_to_fahrenheit(c: f64) -> f64 {
    c * 9.0 / 5.0 + 32.0
}

fn main() {
    println!("{}", celsius_to_fahrenheit(100.0));
}

A //! comment describing the whole module

At the very top of a file, //! describes the file itself rather than the item below it — the first thing a reader (or cargo doc) sees when they open that module.

//! Utilities for working with shopping cart totals.
//!
//! This module has no real submodules here — in a real project
//! this comment would sit at the top of `cart.rs`.

fn main() {
    println!("see the module doc comment above");
}

The TODO/FIXME convention

These aren’t special to Rust — they’re just an ordinary comment with a keyword teams agree to grep for, so unfinished work doesn’t get lost.

fn main() {
    let mut price = 19.99;
    // TODO: apply loyalty discount once the rules are finalized
    // FIXME: this doesn't yet handle negative quantities
    price *= 1.0;
    println!("price: {price}");
}

A doc comment on a struct field

Fields can carry their own /// comment too, so cargo doc explains not just what a struct is, but what each piece of data inside it means.

/// A single row in the user table.
struct User {
    /// The user's display name, shown in the UI.
    name: String,
    /// Account age in days since signup.
    age_days: u32,
}

fn main() {
    let u = User { name: String::from("Ada"), age_days: 42 };
    println!("{} has been here {} days", u.name, u.age_days);
}

A # Panics section warning readers up front

Doc comments have other conventional sections besides # Examples. # Panics tells callers exactly which inputs will crash the program, before they find out the hard way.

/// Divides two numbers.
///
/// # Panics
///
/// Panics if `divisor` is zero.
pub fn divide(n: i32, divisor: i32) -> i32 {
    n / divisor
}

fn main() {
    println!("{}", divide(10, 2));
}

Your turn

This code wants a doc comment on the public function so it shows up in cargo doc, but it’s using the wrong comment style, and the doc comment is in the wrong place. Fix it so the description properly documents greeting. Press ▶ Run.

// Returns a friendly greeting for the app.
pub fn greeting() -> &'static str {
    /// this comment is in the wrong spot
    "Welcome!"
}

fn main() {
    println!("{}", greeting());
}
Show solution

The description belongs above the function as a /// doc comment. The stray /// inside the body has nothing to document, so make it an ordinary // note (or remove it).

/// Returns a friendly greeting for the app.
pub fn greeting() -> &'static str {
    // this note is fine as an ordinary comment
    "Welcome!"
}

fn main() {
    println!("{}", greeting());
}

/// above the function documents it; // inside is just a human note.

Quick check

Remember this

  • // is an ordinary comment; /* ... */ is the block form. Both are ignored by the compiler.
  • /// is a doc comment: it describes the item directly below it and feeds cargo doc.
  • //! documents the thing it’s inside (a module or file), used at the top of files.
  • Good comments explain why, not what — don’t just restate the code.
  • Code examples inside doc comments are run as tests, which keeps docs honest.

Go deeper

Next:

Modules and crates

Beginner · Language basics

What & why

As your program grows past one file, you need a way to organize it into sensible groups and decide what’s shared and what’s private. Rust does this with modules (folders of code inside a project) and crates (whole projects that get compiled). Getting the vocabulary straight now means the “where does this code go?” question stops being scary later.

The idea, slowly

Crate: the whole package

A crate is the unit Rust compiles at once — roughly, “one project.” There are two kinds:

  • A binary crate is a program you can run. It has a main function. Your hello world was a binary crate.
  • A library crate is code meant to be used by other crates. It has no main; instead it offers functions and types for others to call. When you add a dependency from the internet, you’re pulling in a library crate.

You mostly don’t think about crates directly — Cargo (Rust’s build tool) handles them. Just hold the mental model: a crate is a compiled package, either a runnable program or a reusable library.

Module: a labeled drawer inside a crate

A module is a way to group related code and give it a name. Think of a crate as a filing cabinet and modules as the labeled drawers inside. You declare one with mod:

mod parser {
    pub fn parse(input: &str) -> usize {
        input.len()
    }
}

fn main() {
    // reach into the module with :: (two colons)
    let n = parser::parse("hello");
    println!("parsed length is {n}"); // 5
}

Read mod parser { ... } as “here’s a drawer named parser, and here’s what’s inside it.” To use something from the drawer, you write the module’s name, then ::, then the item’s name: parser::parse. The :: is Rust’s “reach inside” operator — a path separator, like a slash in a folder path.

Privacy: pub opens the drawer

Here’s the rule that trips people up: everything in a module is private by default. Private means “only code inside this same module (or its children) can see it.” From outside, it’s invisible.

Look again at the example. The function is written pub fn parse. That pub (public) is what lets main — which is outside the module — call it. Remove the pub and watch it break:

mod parser {
    fn parse(input: &str) -> usize {  // no pub → private
        input.len()
    }
}

fn main() {
    let n = parser::parse("hello");   // ERROR: function `parse` is private
    println!("{n}");
}

Press Run: “function parse is private.” The compiler is protecting the module’s insides. This is a feature: a module can have lots of helper functions it uses internally, and only mark the few it wants the outside world to touch with pub. That handful of pub items is the module’s public interface — its promise to everyone else.

The guiding habit: keep things as private as you can. Only add pub when something genuinely needs to be used from outside. Private code is free to change without breaking anyone.

use saves you from long paths

Writing parser::parse every time gets tiring. The use keyword brings a name into scope so you can refer to it directly:

mod parser {
    pub fn parse(input: &str) -> usize {
        input.len()
    }
}

use parser::parse;   // bring `parse` into scope

fn main() {
    let n = parse("hello");   // now no prefix needed
    println!("{n}");
}

You’ve already used use without thinking — pulling in things like use std::collections::HashMap;. It’s just “let me call this by its short name.”

Modules and files

In small examples, mod parser { ... } with the code right there in braces works fine. In real projects, you usually put a module in its own file. Writing mod parser; (with a semicolon, no braces) tells Rust: “the module parser lives in a file called parser.rs next door — go read it.” The module structure and the file structure line up, but they’re separate ideas: mod declares the module, and the file is just where its contents happen to live. You don’t need this yet; recognize it when you see it.

The standard library is a crate too

Everything you get for free — String, println!, Vec — comes from a library crate called std. When you write std::collections::HashMap, you’re reading a path: the std crate, its collections module, the HashMap inside. Same :: navigation, all the way down.

Common mistakes

  • Forgetting pub. Items are private by default. Calling a module’s function from outside without marking it pub fails with “function is private.” Add pub to the things you want to expose.
  • Making everything pub. The opposite mistake. If every function is public, you can never safely change your internals. Expose only what callers truly need.
  • Confusing mod and use. mod declares a module (creates the drawer or points at its file). use just makes an existing name shorter to type. They are not the same; use alone won’t create a module.
  • Wrong path with ::. parser.parse() (a dot) is not how you reach into a module — that’s method syntax. Use parser::parse() with the double colon for paths.
  • pub on the function but not its enclosing module. If a module is private, marking an inner function pub still won’t let outside code reach it — the whole path must be reachable. Make the parent module pub too if needed.

More examples

A CLI’s flag parser gets its own drawer

Keeping argument-parsing code inside a cli module means main stays focused on running the program, not decoding strings.

mod cli {
    pub fn parse_flag(arg: &str) -> bool {
        arg == "--verbose" || arg == "-v"
    }
}

fn main() {
    let args = ["build", "--verbose"];
    let verbose = args.iter().any(|a| cli::parse_flag(a));
    println!("verbose mode: {}", verbose);
}

Nested modules for a game engine’s systems

A game engine groups unrelated systems — physics, rendering, audio — into their own nested modules so their internals don’t tangle together.

mod game {
    pub mod physics {
        pub fn apply_gravity(velocity_y: f64) -> f64 {
            velocity_y - 9.8
        }
    }
}

fn main() {
    let v = game::physics::apply_gravity(0.0);
    println!("velocity after one tick: {v}");
}

An inventory module for an online store

Stock-checking logic lives behind one pub function in an inventory module, so the rest of the store’s code doesn’t need to know how availability is calculated.

mod inventory {
    pub fn in_stock(quantity: u32) -> bool {
        quantity > 0
    }
}

fn main() {
    let quantity = 0;
    println!("in stock? {}", inventory::in_stock(quantity));
}

A config module for a web server’s defaults

Bundling default settings into a config module gives the rest of the crate one path to reach for instead of scattering constants everywhere.

mod config {
    pub const DEFAULT_PORT: u16 = 8080;

    pub fn describe() -> String {
        format!("listening on port {DEFAULT_PORT}")
    }
}

fn main() {
    println!("{}", config::describe());
    println!("port constant: {}", config::DEFAULT_PORT);
}

Your turn

This program tries to use a function from a module, but it won’t compile. Two things are wrong. Fix it so it prints the length. Press ▶ Run.

mod text_tools {
    fn word_count(input: &str) -> usize {
        input.split(' ').count()
    }
}

fn main() {
    let n = text_tools.word_count("hello there friend");
    println!("word count is {n}");
}
Show solution

The function needs pub so main can see it, and you reach into a module with ::, not a dot.

mod text_tools {
    pub fn word_count(input: &str) -> usize {
        input.split(' ').count()
    }
}

fn main() {
    let n = text_tools::word_count("hello there friend");
    println!("word count is {n}"); // 3
}

pub opens the function to the outside, and text_tools::word_count is the correct path.

Quick check

Remember this

  • A crate is a compiled package: a binary crate runs (main), a library crate is used by others.
  • A module (mod name { ... }) groups related code inside a crate, like a labeled drawer.
  • Everything is private by default; pub exposes an item to code outside its module.
  • Reach into a module with :: (a path), e.g. parser::parse. Method calls use a dot.
  • use brings a name into scope so you can type its short form. The standard library is the std crate.

Go deeper

Next:

Visibility and privacy

Beginner · Language basics

What & why

Think of modules as rooms in a house. Anything you put in a room stays in that room — and in any room nested inside it — unless you put a sign on the door saying it’s open. That’s Rust’s privacy model: everything is private by default, scoped to the module that defines it and that module’s descendants. Deciding what to mark pub, and how widely, is how a crate keeps a small, stable public API while its internals stay free to change.

The idea, slowly

Private by default

mod kitchen {
    fn secret_recipe() -> &'static str {
        "garlic butter"
    }

    pub fn serve() -> &'static str {
        secret_recipe() // fine: same module can see its own private items
    }
}

fn main() {
    println!("{}", kitchen::serve()); // OK: serve is pub
    // println!("{}", kitchen::secret_recipe()); // ERROR: private
}

secret_recipe has no visibility modifier, so it’s private to kitchen — visible to code inside kitchen (like serve), invisible to everything outside it (like main). serve is marked pub, so it’s the crack in the door that lets outside code reach in.

pub: open to anyone who can reach the module

pub exposes an item to any code that can name the path to it — including, if this crate is published as a library, code outside the crate entirely. It’s the widest visibility Rust has.

pub(crate): open within your crate, closed to the outside world

mod inner {
    pub(crate) fn helper() -> i32 { 42 } // visible anywhere in this crate...
    pub fn public_api() -> i32 { helper() } // ...but only this is visible outside it
}

fn main() {
    println!("{}", inner::helper());     // OK: same crate
    println!("{}", inner::public_api()); // OK: fully public
}

If this crate were published as a library, downstream users could call public_api() but would have no way to reach helper() at all — pub(crate) is perfect for “shared internal plumbing” that different modules of your own code need to call, without it becoming part of your promised API.

pub(super): open to just the parent module

mod outer {
    pub fn from_outer() -> i32 {
        inner::only_for_outer()
    }

    mod inner {
        pub(super) fn only_for_outer() -> i32 { 7 } // visible to `outer`, no further
    }
}

fn main() {
    println!("{}", outer::from_outer());
    // outer::inner::only_for_outer(); // ERROR: not visible outside `outer`
}

pub(super) is a narrower pub(crate) — “visible one level up,” useful when a submodule needs to hand something back to its immediate parent without exposing it any further.

pub use: re-exporting so callers don’t need your internal layout

mod shapes {
    pub mod circle {
        pub fn area(r: f64) -> f64 {
            std::f64::consts::PI * r * r
        }
    }
}

pub use shapes::circle::area; // flatten the path at the crate root

fn main() {
    println!("{:.2}", area(2.0));                  // via the re-export
    println!("{:.2}", shapes::circle::area(2.0));   // the real path still works too
}

pub use re-exports an item under a new, usually shorter, path. Callers write area(...) instead of shapes::circle::area(...), and if you later reorganize your internal modules, you only need to update the pub use line — callers’ code doesn’t break.

A pub struct’s fields are still private by default

mod account {
    pub struct Account {
        pub id: u32,
        balance: f64, // private, even though Account itself is pub
    }

    impl Account {
        pub fn new(id: u32, balance: f64) -> Account {
            Account { id, balance }
        }

        pub fn balance(&self) -> f64 {
            self.balance
        }
    }
}

fn main() {
    let acc = account::Account::new(1, 100.0);
    println!("{}", acc.id);        // OK: `id` is pub
    println!("{}", acc.balance()); // OK: through a public getter
    // println!("{}", acc.balance); // ERROR: field `balance` is private
}

What the compiler is thinking: pub on a struct only answers “can code outside this module even name this type?” It says nothing about the fields — each field’s visibility is decided separately, field by field. This is what makes “public struct, private field, public getter” such a common pattern: it lets you change how balance is stored later without breaking anyone who calls .balance().

Common mistakes

  • Marking a struct pub and assuming its fields come along for free. Each field needs its own pub; a pub struct with no pub fields is unconstructible and unreadable from outside its module (unless you provide public methods).
  • Reaching for pub “to be safe.” Every pub item is a permanent promise to your callers — widening visibility later is easy, narrowing it is a breaking change. Start with the narrowest visibility that works (pub(crate)/pub(super)) and widen only when something genuinely needs to leave the crate.
  • Forgetting pub(crate) is invisible to downstream users of a published crate. It’s for sharing across your own modules, not for exposing an API.
  • Expecting privacy to be file-scoped. Privacy follows the module tree, not the filesystem — two modules can share a file, or one module can span several files, and visibility rules only ever care about the module structure.

More examples

A logging library’s public entry point

Callers only ever need info; how the timestamp is formatted is an internal detail that can change without breaking anyone who logs a message.

mod logger {
    fn timestamp() -> &'static str {
        "12:00:00" // private helper, internal detail
    }

    pub fn info(message: &str) {
        println!("[{}] INFO: {}", timestamp(), message);
    }
}

fn main() {
    logger::info("server started");
    // logger::timestamp(); // ERROR: private
}

Sharing a connection pool helper across your crate

pub(crate) is right for plumbing like a connection string builder — other modules in this crate can call it, but it never leaks out if this crate is published as a library.

mod db {
    pub(crate) fn connection_string() -> String {
        String::from("postgres://localhost/app")
    }

    pub fn connect() -> String {
        format!("connected to {}", connection_string())
    }
}

fn main() {
    println!("{}", db::connect());
    println!("{}", db::connection_string()); // OK: same crate
}

A validator reporting back to its parent module only

pub(super) fits a submodule that exists purely to serve its parent — signup needs validation’s result, but nothing else in the crate should be able to reach into it directly.

mod signup {
    pub fn register(email: &str) -> bool {
        validation::is_valid(email)
    }

    mod validation {
        pub(super) fn is_valid(email: &str) -> bool {
            email.contains('@')
        }
    }
}

fn main() {
    println!("{}", signup::register("user@example.com"));
    // signup::validation::is_valid("x"); // ERROR: not visible outside `signup`
}

Re-exporting a formatting helper at the crate root

pub use lets callers write title_case(...) directly instead of remembering that it actually lives two modules deep inside text_utils::format.

mod text_utils {
    pub mod format {
        pub fn title_case(s: &str) -> String {
            let mut chars = s.chars();
            match chars.next() {
                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
                None => String::new(),
            }
        }
    }
}

pub use text_utils::format::title_case;

fn main() {
    println!("{}", title_case("rust"));                       // via the re-export
    println!("{}", text_utils::format::title_case("humans")); // the real path still works
}

Your turn

This program tries to construct an item type from outside the module that defines it.

mod inventory {
    struct Item {
        name: String,
        pub price: f64,
    }

    pub fn cheapest_name() -> String {
        let item = Item { name: String::from("Widget"), price: 9.99 };
        item.name
    }
}

fn main() {
    println!("{}", inventory::cheapest_name());
    let item = inventory::Item { name: String::from("Gadget"), price: 4.5 };
    println!("{}", item.price);
}
Show solution

Item itself has no pub — the type is private to inventory, so inventory::Item can’t even be named from main, regardless of any individual field’s visibility. Making Item public but keeping name private also means outside code can’t build one with a struct literal (it can’t fill in a private field) — so it needs a public constructor:

mod inventory {
    pub struct Item {
        name: String,
        pub price: f64,
    }

    impl Item {
        pub fn new(name: &str, price: f64) -> Item {
            Item { name: name.to_string(), price }
        }
    }

    pub fn cheapest_name() -> String {
        let item = Item::new("Widget", 9.99);
        item.name
    }
}

fn main() {
    println!("{}", inventory::cheapest_name());
    let item = inventory::Item::new("Gadget", 4.5);
    println!("{}", item.price); // OK: `price` is pub
}

Item is now pub so its name is reachable from outside inventory; name stays private (only inventory’s own code ever touches it directly); and Item::new is the public door for constructing one, since a struct literal can’t set a private field from outside its module.

Quick check

Remember this

  • Everything is private by default, scoped to its defining module and that module’s descendants.
  • pub exposes an item to anyone who can reach the module — including, for a library crate, outside users.
  • pub(crate) exposes something across your whole crate, but never to downstream users of a published crate.
  • pub(super) exposes something to just the parent module, one level up.
  • pub use re-exports an item under a new path, letting callers ignore your internal module layout.
  • A pub struct’s fields are still private unless each one is marked pub individually.

Go deeper

Next:

Structs

Beginner · Language basics

What & why

A struct lets you glue several related values together into one named type. Instead of juggling three loose variables — user_id, user_name, user_active — you bundle them into a single User value that carries all three. Structs are how you model the “things” in your program: a user, a point, a config, an order. They’re one of the two building blocks of Rust types (enums are the other).

The idea, slowly

Defining a struct

You define a struct with the struct keyword and a list of fields, each with a name and a type:

struct User {
    id: u64,
    name: String,
    active: bool,
}

fn main() {
    println!("defined a User struct");
}

Think of this as designing a form: a User always has an id, a name, and an active flag. This is just the blueprint — no actual user exists yet. It’s like a cookie cutter, not a cookie.

By convention, struct names use CamelCase (each word capitalized) and field names use snake_case (lowercase with underscores).

Creating an instance

To make an actual User, you fill in every field by name:

struct User {
    id: u64,
    name: String,
    active: bool,
}

fn main() {
    let user = User {
        id: 1,
        name: String::from("Shaon"),
        active: true,
    };

    println!("user id is {}", user.id);   // reach a field with a dot
    println!("name is {}", user.name);
}

User { id: 1, name: ..., active: true } is a struct literal: you list each field and the value it should hold. You must fill in every field — Rust won’t let you forget one. Once you have a user, you read a field with a dot: user.id, user.name. Same dot you’d use in many languages.

Changing a field needs mut

Just like plain variables, a struct is immutable unless you say mut. And it’s the whole struct that’s mutable or not — you can’t make just one field changeable:

struct User {
    id: u64,
    name: String,
    active: bool,
}

fn main() {
    let mut user = User {
        id: 1,
        name: String::from("Shaon"),
        active: true,
    };

    user.active = false;   // allowed because `user` is mut
    println!("active? {}", user.active);
}

Drop the mut and user.active = false; fails with “cannot assign to … immutable.” The mut on let covers every field of the struct.

A constructor pattern

Typing out every field each time gets old, especially when some values are always the same. A common habit is to write a function that builds the struct for you:

struct User {
    id: u64,
    name: String,
    active: bool,
}

fn new_user(id: u64, name: String) -> User {
    User {
        id,
        name,          // shorthand: field `name` gets the variable `name`
        active: true,  // sensible default
    }
}

fn main() {
    let user = new_user(7, String::from("Rust"));
    println!("{} is active? {}", user.name, user.active);
}

Two things to notice. First, active: true bakes in a default so callers don’t have to think about it. Second, the field init shorthand: when a variable has the same name as the field (id, name), you can write just id instead of id: id. Rust matches them up. It’s a small convenience you’ll see everywhere.

(Later you’ll learn to attach this constructor to the type as User::new(...) using an impl block — that’s the Methods lesson. This plain function does the same job for now.)

Tuple structs: names without field names

Sometimes you want a distinct type but the fields don’t need names. A tuple struct gives you that:

struct Point(i32, i32);   // two i32s, no field names

fn main() {
    let origin = Point(0, 0);
    println!("x = {}, y = {}", origin.0, origin.1);  // reach by position
}

Point(0, 0) looks like a tuple but it’s its own named type — a Point can’t be mixed up with some other (i32, i32). You reach into it by position (.0, .1) like a tuple. Use these sparingly; named fields are usually clearer.

Why bother, instead of loose variables?

You could track id, name, and active as three separate variables. But then nothing ties them together — you could pass the wrong name with the wrong id and Rust couldn’t help. Bundling them in a User makes “a user” a real thing the compiler understands, so you can pass one value around, and the pieces travel together and stay in sync.

Common mistakes

  • Forgetting a field in the literal. User { id: 1 } when the struct also needs name and active fails with “missing fields.” You must provide every field when constructing (unless you use struct update syntax to copy the rest from another instance).
  • Trying to make one field mutable. There’s no mut on individual fields. Mutability lives on the binding: let mut user = ... makes the whole struct changeable. If user isn’t mut, no field can be assigned.
  • Confusing the blueprint with an instance. struct User { ... } defines the type; it does not create a user. You still need let user = User { ... } to get an actual value.
  • Wrong field name or type. User { naem: ... } (typo) or putting a number where a String goes is a compile error. Fields must match the definition exactly.
  • Reaching into a struct with :: instead of .. Fields use a dot: user.name. The :: is for paths and associated functions, not field access.

More examples

Modeling a game character

A Character struct keeps a player’s name, health, and level together, so taking damage is just updating one field on one value instead of juggling three variables.

struct Character {
    name: String,
    hp: u32,
    level: u32,
}

fn main() {
    let mut hero = Character {
        name: String::from("Aria"),
        hp: 100,
        level: 1,
    };

    hero.hp -= 30; // took damage
    println!("{} is at {} hp (level {})", hero.name, hero.hp, hero.level);
}

Representing an RGB color

A tuple struct is a good fit here — a color is just three numbers, and naming each one (red, green, blue) would add ceremony without adding clarity.

struct Color(u8, u8, u8);

fn main() {
    let warning = Color(255, 165, 0);
    println!("rgb({}, {}, {})", warning.0, warning.1, warning.2);
}

Building a server config with sensible defaults

A constructor function bakes in the common case — 127.0.0.1:8080 — so most callers never have to think about every field.

struct ServerConfig {
    host: String,
    port: u16,
}

fn default_config() -> ServerConfig {
    ServerConfig {
        host: String::from("127.0.0.1"),
        port: 8080,
    }
}

fn main() {
    let config = default_config();
    println!("serving on {}:{}", config.host, config.port);
}

Totaling a shopping cart line item

Bundling a cart line’s name, price, and quantity into one struct means the subtotal calculation always uses matching values, never a price from one item paired with another’s quantity.

struct CartItem {
    name: String,
    price: f64,
    quantity: u32,
}

fn main() {
    let item = CartItem {
        name: String::from("Notebook"),
        price: 4.50,
        quantity: 3,
    };

    let subtotal = item.price * item.quantity as f64;
    println!("{} x{}: ${:.2}", item.name, item.quantity, subtotal);
}

Your turn

This program defines a Book and tries to build and update one, but it won’t compile. There are two problems. Fix it so it prints the title and the updated year. Press ▶ Run.

struct Book {
    title: String,
    year: u32,
}

fn main() {
    let book = Book {
        title: String::from("Rust for Humans"),
    };

    book.year = 2026;
    println!("{} ({})", book.title, book.year);
}
Show solution

The literal is missing the year field (every field is required), and book must be mut before you can change year.

struct Book {
    title: String,
    year: u32,
}

fn main() {
    let mut book = Book {
        title: String::from("Rust for Humans"),
        year: 2025,
    };

    book.year = 2026;
    println!("{} ({})", book.title, book.year);
}

Now both fields are provided and the mut allows the update.

Quick check

Remember this

  • A struct bundles related named values (fields) into one type — a blueprint, not a value.
  • Create one with a struct literal, filling in every field: User { id: 1, name: ..., active: true }.
  • Read fields with a dot (user.name); mutating any field needs let mut on the whole binding.
  • Field init shorthand: name instead of name: name when the variable and field share a name.
  • Tuple structs (struct Point(i32, i32)) are named types with positional fields; use named fields when you can.

Go deeper

Next:

Derivable traits

Beginner · Language basics

What & why

Every struct or enum you write eventually needs the same handful of boring abilities: print itself for debugging, get copied, get compared for equality, get a sensible default, get sorted. Writing those by hand for every type would be pure boilerplate — so Rust lets the compiler generate a mechanical, field-by-field implementation with one line: #[derive(...)]. It’s the single most common attribute in everyday Rust code.

The idea, slowly

#[derive(Debug)] — printable for developers

#[derive(Debug)]
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 1, y: 2 };
    println!("{p:?}"); // Point { x: 1, y: 2 }
}

This generates an implementation of std::fmt::Debug that prints the struct’s name and every field’s value. It’s the first derive most people reach for, because without it {:?} — and therefore most debugging — doesn’t compile.

#[derive(Clone)] and #[derive(Copy)]

Clone gives you an explicit .clone() method that makes a deep copy — you always have to ask for it. Copy is different: it’s a marker that changes what assignment means. Without Copy, let b = a; moves a into b (a becomes unusable). With Copy, that same line silently duplicates the value instead, and both a and b stay usable.

#[derive(Debug, Clone, Copy)]
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let a = Point { x: 1, y: 2 };
    let b = a; // copied, not moved — `a` is still valid
    println!("{a:?} {b:?}");
}

Copy only works when every field is itself Copy — no String, Vec, Box, or anything else that owns heap data, because those can’t be safely duplicated by just copying bits. And Copy requires Clone (trait Copy: Clone) — a Copy type is always also a Clone type, since “copy” is really just “the cheap, implicit version of clone.”

#[derive(PartialEq, Eq)] — equality

PartialEq gives you == and !=, comparing every field. Eq is a marker with no methods — it promises the comparison is fully reflexive (a == a is always true), which floats can’t promise because NaN != NaN. That’s why f64/f32 implement PartialEq but not Eq.

#[derive(Debug, PartialEq, Eq)]
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let a = Point { x: 1, y: 2 };
    let b = Point { x: 1, y: 2 };
    println!("{}", a == b); // true — compared field by field
}

#[derive(Hash)] — usable as a HashMap/HashSet key

use std::collections::HashSet;

#[derive(Debug, PartialEq, Eq, Hash)]
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let mut seen = HashSet::new();
    seen.insert(Point { x: 1, y: 2 });
    println!("{}", seen.contains(&Point { x: 1, y: 2 })); // true
}

HashMap/HashSet keys need both Eq and HashEq so two equal keys are recognized as the same key, Hash so they land in the same bucket.

#[derive(Default)] — a sensible zero value

#[derive(Debug, Default)]
struct Config {
    verbose: bool,
    retries: u32,
    name: String,
}

fn main() {
    let c = Config::default();
    println!("{c:?}"); // Config { verbose: false, retries: 0, name: "" }

    let c2 = Config { retries: 3, ..Default::default() }; // override just one field
    println!("{c2:?}");
}

Default fills every field with its type’s default (false, 0, "", None, …). The ..Default::default() struct-update syntax is the everyday pattern for “give me the default, except for this one field.”

#[derive(PartialOrd, Ord)] — comparison and sorting

Derived ordering compares fields in declaration order, top to bottom — exactly like comparing tuples. The first field is the most significant.

#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
struct Version {
    major: u32,
    minor: u32,
    patch: u32,
}

fn main() {
    let mut versions = vec![
        Version { major: 1, minor: 2, patch: 0 },
        Version { major: 1, minor: 0, patch: 5 },
        Version { major: 2, minor: 0, patch: 0 },
    ];
    versions.sort();
    println!("{versions:?}"); // ordered by major, then minor, then patch
}

PartialOrd gives you <, <=, >, >=; Ord (which requires Eq) is what .sort(), BTreeMap keys, and BinaryHeap actually need, because it promises every pair of values can be compared — floats can’t derive Ord for the same NaN reason they can’t derive Eq.

Why deriving fails, and when to hand-write instead

What the compiler is thinking: #[derive(Trait)] expands to “implement Trait by calling Trait’s method on every field, in order.” That only type-checks if every field’s type already implements Trait. One field without Debug blocks #[derive(Debug)] on the whole struct — the derive doesn’t skip it, it fails to compile.

Hand-write the trait instead of deriving when the mechanical, field-by-field behavior isn’t the behavior you want:

  • A case-insensitive string wrapper needs custom PartialEq/Hash so "Rust" and "rust" compare and hash as equal.
  • A struct with an internal cache field shouldn’t have that field affect equality.
  • A priority queue often wants Ord based on just one field (or reversed), not every field in declaration order.

Common mistakes

  • Deriving Copy on a struct with a String/Vec/Box field. Fails with “the trait Copy may not be implemented for this type” — those fields own heap data and can’t be bitwise-duplicated safely.
  • Deriving Eq/Ord on a struct containing an f64/f32 field. Floats aren’t Eq/Ord because of NaN; you can derive PartialEq/PartialOrd on them, but not the stricter traits.
  • Assuming a missing field trait gets silently skipped. It doesn’t — the whole #[derive(...)] fails to compile if any field lacks that trait.
  • Assuming derived ordering compares “by importance.” It compares fields in the order you wrote them in the struct — reorder the fields to change sort priority, or hand-write Ord.

More examples

Snapshotting a game save before risking it

Clone gives a checkpoint an independent copy to fall back to — mutating current afterward can’t touch checkpoint, because they no longer share any data.

#[derive(Debug, Clone)]
struct GameSave {
    level: u32,
    hp: u32,
}

fn main() {
    let checkpoint = GameSave { level: 3, hp: 80 };
    let mut current = checkpoint.clone(); // keep the checkpoint safe before risking hp
    current.hp -= 30; // took damage

    println!("checkpoint: {:?}", checkpoint);
    println!("current: {:?}", current);
}

Deduplicating scanned badge IDs at a gate

PartialEq, Eq, and Hash together are what let a struct sit inside a HashSet — here that’s the difference between silently re-admitting a badge and catching a repeat scan.

use std::collections::HashSet;

#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
struct BadgeId(u32);

fn main() {
    let mut checked_in: HashSet<BadgeId> = HashSet::new();
    let scans = [BadgeId(101), BadgeId(102), BadgeId(101), BadgeId(103)];

    for badge in scans {
        if !checked_in.insert(badge) {
            println!("badge {} already checked in", badge.0);
        }
    }
    println!("{} unique badges scanned", checked_in.len());
}

Sorting a to-do list by priority

Deriving Ord on Task means .sort() just works — it compares priority first because that’s the field declared first, exactly the order a to-do list should sort by.

#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
struct Task {
    priority: u8,
    name: String,
}

fn main() {
    let mut todos = vec![
        Task { priority: 2, name: String::from("write report") },
        Task { priority: 1, name: String::from("fix critical bug") },
        Task { priority: 3, name: String::from("reply to email") },
    ];

    todos.sort();

    for task in &todos {
        println!("[{}] {}", task.priority, task.name);
    }
}

Defaulting an API pagination request

Default plus ..Default::default() lets a request override just the fields a caller cares about — here, only the page number — while every other field falls back sensibly.

#[derive(Debug, Default)]
struct Pagination {
    page: u32,
    per_page: u32,
    sort_desc: bool,
}

fn main() {
    let default_request = Pagination::default();
    println!("{:?}", default_request);

    let page_two = Pagination { page: 2, ..Default::default() };
    println!("{:?}", page_two);
}

Your turn

This struct tries to derive Copy, but one of its fields makes that impossible.

#[derive(Debug, Clone, Copy)]
struct Player {
    name: String,
    score: u32,
}

fn main() {
    let p1 = Player { name: String::from("Ferris"), score: 10 };
    let p2 = p1; // relies on Copy
    println!("{} {}", p1.name, p2.name);
}
Show solution

String owns a heap allocation and doesn’t implement Copy, so Player can’t derive Copy either — the compiler rejects the whole #[derive(...)] line. Keep Clone (which String does support) and copy explicitly when you need two independent values:

#[derive(Debug, Clone)]
struct Player {
    name: String,
    score: u32,
}

fn main() {
    let p1 = Player { name: String::from("Ferris"), score: 10 };
    let p2 = p1.clone(); // explicit deep copy, since Player isn't Copy
    println!("{} {}", p1.name, p2.name);
}

Quick check

Remember this

  • #[derive(Debug)] enables {:?} printing; add it to nearly every type you define.
  • Clone is an explicit deep copy (.clone()); Copy makes assignment implicitly duplicate — only for types where every field is itself Copy, and Copy requires Clone.
  • PartialEq/Eq enable ==; PartialOrd/Ord enable < and sorting — floats can’t derive Eq/Ord because of NaN.
  • Hash (together with Eq) is what lets a type be a HashMap/HashSet key.
  • Default fills every field with its type’s default value; ..Default::default() overrides just some fields.
  • Deriving requires every field to implement that trait — one missing field fails the whole derive.
  • Hand-write a trait instead of deriving when the mechanical field-by-field behavior isn’t the behavior you actually want.

Go deeper

Next:

Enums

Beginner · Language basics

What & why

An enum lets you say “this value is exactly one of these few possibilities.” A traffic light is red, yellow, or green — never all three, never something else. A struct bundles values that are all present together; an enum picks one from a list of options. This “one of” idea is quietly one of Rust’s most powerful tools, and it’s the foundation for how Rust handles missing values and errors safely.

The idea, slowly

The simplest enum: a set of choices

You define an enum with the enum keyword and list the possibilities, called variants:

enum Direction {
    North,
    South,
    East,
    West,
}

fn main() {
    let heading = Direction::North;   // pick one variant
    // ... we'll do something with it below
    println!("picked a direction");
}

Direction is a type whose value can be exactly one of four things. You choose a variant with EnumName::Variant, so Direction::North. The :: reaches into the enum to grab the variant, the same path syntax you saw with modules.

A heading is always a valid Direction — there’s no way to accidentally create a fifth direction. The compiler knows the complete list, and that turns out to be incredibly useful (see pattern matching below).

The powerful part: variants can carry data

Here’s what makes Rust enums special. Each variant can hold its own data, and different variants can hold different shapes of data:

enum Message {
    Quit,                       // holds nothing
    Move { x: i32, y: i32 },    // holds two named fields, like a struct
    Write(String),              // holds a String
    ChangeColor(i32, i32, i32), // holds three numbers, like a tuple
}

fn main() {
    let a = Message::Quit;
    let b = Message::Move { x: 10, y: 20 };
    let c = Message::Write(String::from("hello"));
    let d = Message::ChangeColor(255, 0, 0);
    println!("built four different messages");
}

Read this slowly. A Message is one of four things. If it’s a Write, it carries a String. If it’s a Move, it carries an x and a y. If it’s Quit, it carries nothing at all. One type, four different possible shapes, and a value is always exactly one of them.

This is why enums are called the backbone of Rust modeling. You could try to represent this with a struct full of optional fields and a “kind” flag, but then nothing stops you from having a Write message with Move data filled in. The enum makes the illegal combinations impossible to build.

Option: the enum that replaces null

You will meet one enum constantly, and it’s built into Rust: Option. Many languages have null — a special “nothing here” value that causes crashes when you forget to check for it. Rust has no null. Instead it uses an enum:

fn find_first_even(numbers: &[i32]) -> Option<i32> {
    for &n in numbers {
        if n % 2 == 0 {
            return Some(n);   // found one: wrap it in Some
        }
    }
    None                       // found nothing
}

fn main() {
    let result = find_first_even(&[1, 3, 4, 7]);
    println!("{:?}", result);          // Some(4)
    let empty = find_first_even(&[1, 3, 5]);
    println!("{:?}", empty);           // None
}

Option<i32> means “either an i32, or nothing.” Its two variants are Some(value) (there’s a value) and None (there isn’t). Because the “nothing” case is baked into the type, Rust forces you to handle it — you can’t accidentally use a missing value like you can with null. That single design choice removes a whole category of crashes.

(The {:?} in println! is a debug print — handy for inspecting values while learning. And Result, the enum for success-or-error, works the same way; it’s in the Error handling lesson.)

Enums shine with match

An enum on its own is just a value. Where it becomes powerful is pairing it with match, which lets you handle each variant and pull the data back out:

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
}

fn describe(msg: Message) -> String {
    match msg {
        Message::Quit => String::from("quit"),
        Message::Move { x, y } => format!("move to {x}, {y}"),
        Message::Write(text) => format!("write: {text}"),
    }
}

fn main() {
    println!("{}", describe(Message::Move { x: 1, y: 2 }));
    println!("{}", describe(Message::Write(String::from("hi"))));
}

Notice how match both branches on which variant it is and unpacks the data inside (x, y, text). The next lesson (pattern matching) is entirely about this. For now, the key insight: enums and match are two halves of one idea. You design the possibilities with an enum, then handle them with match.

Common mistakes

  • Forgetting the EnumName:: prefix. You write Direction::North, not just North. Without the prefix Rust doesn’t know which enum you mean (unless you brought it into scope with use).
  • Treating a variant’s data as always accessible. You can’t just read the String out of Message::Write directly — the value might be a different variant. You get the data by matching, which is exactly why match exists.
  • Reaching for null habits. There’s no null in Rust. “Might be missing” is Option, and you must handle the None case. Trying to skip it won’t compile.
  • Overusing structs where an enum fits. If you find yourself with a “type” field and a pile of fields that are only sometimes filled in, that’s an enum trying to be born. Enums make “one of these shapes” explicit and safe.
  • Non-exhaustive match. When you match an enum, you must handle every variant (or use _ for the rest). Miss one and the compiler stops you — a feature that catches bugs when you add a new variant later.

More examples

A vending machine’s possible states

The machine is always in exactly one state, and only Dispensing needs extra data — a perfect fit for an enum where each variant carries its own shape of information.

enum VendingState {
    Idle,
    Dispensing(String),
    OutOfStock,
}

fn status_message(state: VendingState) -> String {
    match state {
        VendingState::Idle => String::from("insert coin to start"),
        VendingState::Dispensing(item) => format!("dispensing {}", item),
        VendingState::OutOfStock => String::from("sold out, please choose another item"),
    }
}

fn main() {
    println!("{}", status_message(VendingState::Idle));
    println!("{}", status_message(VendingState::Dispensing(String::from("chips"))));
    println!("{}", status_message(VendingState::OutOfStock));
}

Cycling a traffic light through its phases

A match that returns a different variant for each input turns an enum into a tiny state machine — calling .next() repeatedly cycles the light through its phases forever.

#[derive(Debug)]
enum TrafficLight {
    Red,
    Yellow,
    Green,
}

impl TrafficLight {
    fn next(self) -> TrafficLight {
        match self {
            TrafficLight::Red => TrafficLight::Green,
            TrafficLight::Green => TrafficLight::Yellow,
            TrafficLight::Yellow => TrafficLight::Red,
        }
    }
}

fn main() {
    let mut light = TrafficLight::Red;
    for _ in 0..4 {
        println!("{:?}", light);
        light = light.next();
    }
}

Parsing command-line arguments

A CLI’s set of valid commands is naturally “one of a few options, one of which carries an argument” — exactly what an enum with a data-carrying variant like Run(String) was built for.

enum Command {
    Help,
    Version,
    Run(String),
}

fn execute(cmd: Command) {
    match cmd {
        Command::Help => println!("usage: app [help|version|run <task>]"),
        Command::Version => println!("app v1.0.0"),
        Command::Run(task) => println!("running task: {}", task),
    }
}

fn main() {
    execute(Command::Help);
    execute(Command::Version);
    execute(Command::Run(String::from("build")));
}

Guarding against division by zero

Returning Option<f64> instead of a plain f64 forces every caller to confront the “what if b is zero” case at compile time, instead of letting it crash or produce inf silently.

fn safe_divide(a: f64, b: f64) -> Option<f64> {
    if b == 0.0 {
        None
    } else {
        Some(a / b)
    }
}

fn main() {
    match safe_divide(10.0, 2.0) {
        Some(result) => println!("10 / 2 = {}", result),
        None => println!("cannot divide by zero"),
    }

    match safe_divide(5.0, 0.0) {
        Some(result) => println!("5 / 0 = {}", result),
        None => println!("cannot divide by zero"),
    }
}

Your turn

This program models a coin and tries to get its value, but it won’t compile. The variant is referenced without its enum name, and one variant is missing from the match. Fix it. Press ▶ Run.

enum Coin {
    Penny,
    Nickel,
    Dime,
}

fn value(coin: Coin) -> u32 {
    match coin {
        Penny => 1,
        Nickel => 5,
    }
}

fn main() {
    println!("{}", value(Coin::Dime));
}
Show solution

Each pattern needs the Coin:: prefix, and match must cover every variant — Dime was missing.

enum Coin {
    Penny,
    Nickel,
    Dime,
}

fn value(coin: Coin) -> u32 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
    }
}

fn main() {
    println!("{}", value(Coin::Dime)); // 10
}

With all three variants handled and properly prefixed, the match is exhaustive and compiles.

Quick check

Remember this

  • An enum says a value is exactly one of several variants — pick one with EnumName::Variant.
  • Variants can carry data, and each can carry a different shape (nothing, a tuple, or named fields).
  • Enums make illegal states impossible to build, which is why they’re great for modeling.
  • Option (Some(value) / None) is Rust’s built-in replacement for null — missing values are handled, not ignored.
  • Enums pair with match to branch on the variant and unpack its data — that’s the next lesson.

Go deeper

Next:

Pattern matching

Intermediate · Language basics

What & why

Pattern matching is how you look at a value, figure out which shape it has, and pull the pieces out — all in one move. It’s the natural partner to enums: you built a value that could be one of several things, and match is how you handle each thing. Once it clicks, a lot of Rust code that looked mysterious (if let, match, destructuring) turns out to be the same simple idea wearing different clothes.

The idea, slowly

match: one value, many branches

A match compares a value against a list of patterns, top to bottom, and runs the first one that fits:

fn main() {
    let number = 3;

    match number {
        1 => println!("one"),
        2 => println!("two"),
        3 => println!("three"),
        _ => println!("something else"),
    }
}

Read each line as “if the value looks like this, do that.” The => separates the pattern (left) from the code to run (right). The compiler checks each arm in order and stops at the first match.

That last arm, _, is the catch-all — an underscore that means “anything not already handled.” It’s like the default case in other languages. It matters because of Rust’s big rule below.

The rule: match must be exhaustive

Rust insists that a match cover every possible value. If you leave a case out, it won’t compile:

fn main() {
    let flag = true;

    match flag {
        true => println!("yes"),
        // missing the `false` case!
    }
}

Press Run: “non-exhaustive patterns: false not covered.” At first this feels bossy. But it’s a gift: it means you can never forget a case. If you add a new enum variant six months from now, every match that doesn’t handle it lights up red immediately. The compiler is thinking: “you claim to handle this value — prove you handled all of it.”

You satisfy exhaustiveness either by listing every case, or by adding _ to sweep up the rest.

Matching an enum and unpacking its data

This is where match earns its keep. When you match an enum variant that carries data, the pattern names that data so you can use it:

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
}

fn main() {
    let msg = Message::Write(String::from("hello"));

    match msg {
        Message::Quit => println!("bye"),
        Message::Move { x, y } => println!("move to {x}, {y}"),
        Message::Write(text) => println!("writing: {text}"),
    }
}

Look at Message::Write(text). The text isn’t a value you already have — it’s a name you’re inventing to capture whatever String is inside this Write. Same with x and y in the Move arm. This is destructuring: the pattern mirrors the shape of the data, and Rust hands you the inner pieces under the names you chose. One construct branches and extracts, together.

if let: the shortcut for one case

Sometimes you only care about one variant and want to ignore everything else. Writing a full match with a _ => () throwaway arm is clunky. if let is the compact form:

fn main() {
    let maybe_number: Option<i32> = Some(7);

    if let Some(n) = maybe_number {
        println!("got a number: {n}");
    } else {
        println!("nothing here");
    }
}

Read if let Some(n) = maybe_number as: “if maybe_number matches the pattern Some(n), then bind that inner value to n and run the block.” It’s a match with only one interesting arm. Use if let when a full match would be overkill; use match when you genuinely handle several cases.

Patterns show up in more than match

Once you see patterns as “shapes with names,” you’ll spot them elsewhere. A let can destructure:

fn main() {
    let (a, b, c) = (1, 2, 3);      // unpack a tuple into three names
    println!("{a} {b} {c}");

    let point = (10, 20);
    let (x, y) = point;             // same idea
    println!("x={x}, y={y}");
}

let (a, b, c) = ... is a pattern too — it takes apart the tuple and binds each slot. So the destructuring you do in a match arm is the very same mechanism as a let that pulls a tuple apart. It’s all one idea.

Extra tools: multiple patterns and guards

Two handy extras. You can match several values in one arm with | (“or”), and you can add an if condition (a guard) to an arm:

fn main() {
    let n = 5;

    match n {
        1 | 2 | 3 => println!("small"),        // matches 1, 2, or 3
        x if x > 100 => println!("huge: {x}"), // matches, but only if x > 100
        _ => println!("in between"),
    }
}

The | lets one arm cover multiple patterns, and x if x > 100 only fires when both the pattern and the extra condition hold. You don’t need these often, but they’re there when a plain pattern isn’t enough.

Common mistakes

  • Non-exhaustive match. Leaving out a case fails with “non-exhaustive patterns.” Handle every variant or add _. This is the whole point of match, so lean into it rather than fighting it.
  • Putting _ too early. Arms are checked top to bottom, so a _ (or any broad pattern) placed above specific ones will swallow them, and Rust warns the later arms are “unreachable.” Keep the catch-all last.
  • Reaching for the inner data without matching. You can’t do msg.text on an enum value — it might be a different variant. You get the inner value by matching (or if let), which is exactly what these tools are for.
  • Using match when if let reads better (or vice versa). A match with one real arm and a _ => () is usually clearer as if let. Conversely, chaining many if lets where a single match would do makes code harder to follow.
  • Forgetting patterns bind new names. In Some(n), n is a fresh name capturing the inner value — it does not compare against an existing variable called n. This surprises people who expect it to mean “match only if equal to n.”

More examples

Reading an HTTP status code

A web client needs to turn a raw status number into a human-readable category, and a range pattern like 500..=599 covers a whole band of codes in one arm.

fn describe_status(code: u16) -> &'static str {
    match code {
        200 => "OK",
        404 => "Not Found",
        500..=599 => "Server Error",
        _ => "Unknown",
    }
}

fn main() {
    println!("{}", describe_status(200));
    println!("{}", describe_status(503));
    println!("{}", describe_status(999));
}

Locating a point on a graph

Plotting software needs to classify a coordinate by which quadrant it falls in, and guards let each arm add its own condition on top of the tuple pattern.

fn quadrant(point: (i32, i32)) -> &'static str {
    match point {
        (0, 0) => "origin",
        (x, 0) if x > 0 => "positive x-axis",
        (x, y) if x > 0 && y > 0 => "quadrant I",
        (x, y) if x < 0 && y > 0 => "quadrant II",
        _ => "elsewhere",
    }
}

fn main() {
    println!("{}", quadrant((0, 0)));
    println!("{}", quadrant((3, 4)));
    println!("{}", quadrant((-2, 5)));
}

Applying an optional coupon code

A checkout flow only needs to handle “there’s a coupon” — if let skips the ceremony of a full match when the fallback is just “charge full price.”

fn apply_discount(price: f64, coupon: Option<u32>) -> f64 {
    if let Some(percent) = coupon {
        price - (price * percent as f64 / 100.0)
    } else {
        price
    }
}

fn main() {
    println!("{:.2}", apply_discount(100.0, Some(20)));
    println!("{:.2}", apply_discount(100.0, None));
}

Tagging log lines by severity

A log viewer destructures each (level, message) entry with a for loop, then matches on the level to decide how to display it.

fn main() {
    let entries = vec![("ERROR", "disk full"), ("INFO", "server started"), ("WARN", "low memory")];

    for (level, message) in &entries {
        match *level {
            "ERROR" => println!("[ERROR] {message}"),
            "WARN" => println!("[WARN] {message}"),
            _ => println!("[INFO] {message}"),
        }
    }
}

Your turn

This program should describe an Option, but it won’t compile. The match is missing a case, and one arm tries to reach into the value the wrong way. Fix it. Press ▶ Run.

fn describe(value: Option<i32>) -> String {
    match value {
        Some => format!("got {}", value.0),
    }
}

fn main() {
    println!("{}", describe(Some(42)));
    println!("{}", describe(None));
}
Show solution

The Some variant carries a value, so the pattern must name it: Some(n). And the match must also handle None to be exhaustive.

fn describe(value: Option<i32>) -> String {
    match value {
        Some(n) => format!("got {n}"),
        None => String::from("got nothing"),
    }
}

fn main() {
    println!("{}", describe(Some(42))); // got 42
    println!("{}", describe(None));     // got nothing
}

Some(n) destructures the inner number into n, and the None arm makes the match cover every case.

Quick check

Remember this

  • match compares a value to patterns top-to-bottom and runs the first that fits; => separates pattern from action.
  • A match must be exhaustive — cover every case or add _ as a catch-all (kept last).
  • Patterns destructure: Some(n) or Move { x, y } both branch and bind the inner data to new names.
  • if let is the compact form for handling just one variant; use it when a full match is overkill.
  • Patterns also work in let bindings (let (a, b) = pair), with | for multiple patterns and if guards for extra conditions.

Go deeper

Next:

Methods and impl blocks

Intermediate · Language basics

What & why

A method is a function that belongs to a type. Instead of area(rectangle), you write rectangle.area() — the data and the behavior that acts on it live together. You’ve been calling methods all along: text.len(), numbers.push(5), String::from("hi"). This lesson shows you how to write your own, using impl blocks, and how to pick the right self so ownership stays happy.

The idea, slowly

The impl block: attaching behavior to a type

You define a struct (the data), then write an impl block (“implementation”) to hang methods on it:

struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

fn main() {
    let rect = Rectangle { width: 3, height: 4 };
    println!("area is {}", rect.area()); // 12
}

Read impl Rectangle { ... } as “here are the things a Rectangle can do.” Inside, area looks almost like a normal function, except its first parameter is the special word self. When you call rect.area(), Rust passes rect in as self automatically. So inside the method, self is the rectangle you called it on, and self.width reaches its field.

The dot before area is the giveaway: rect.area() is method-call syntax, and self is the value on the left of the dot.

The three receivers: &self, &mut self, self

The first parameter of a method — called the receiver — comes in three flavors, and choosing the right one is really a question about ownership (remember the Ownership lesson):

  • &self — “let me look at the data.” Borrows the value immutably. Use this when the method only reads. Most methods are &self.
  • &mut self — “let me change the data.” Borrows mutably. Use this when the method needs to modify a field.
  • self — “give me the value; I’m taking it.” Takes ownership, consuming the value. Use this rarely — only when the method transforms the value into something else and the original shouldn’t be used afterward.

Here they are side by side:

struct Counter {
    count: u32,
}

impl Counter {
    fn get(&self) -> u32 {          // reads only → &self
        self.count
    }

    fn increment(&mut self) {       // changes a field → &mut self
        self.count += 1;
    }

    fn into_total(self) -> u32 {    // consumes self → self
        self.count
    }
}

fn main() {
    let mut c = Counter { count: 0 };
    c.increment();
    c.increment();
    println!("count is {}", c.get()); // 2

    let total = c.into_total();       // c is consumed here
    println!("final total {total}");
    // c can no longer be used — it was moved into into_total
}

Think of &self as borrowing your friend’s book to read it, &mut self as borrowing it to scribble a note, and self as them giving you the book for keeps. The compiler enforces this: to call increment, the variable must be mut, because you’re borrowing it mutably.

Associated functions: methods without a receiver

Some functions belong to a type but don’t act on an existing instance — most commonly, functions that create one. These leave off self entirely and are called associated functions. You call them with :: instead of a dot:

struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    // no self → associated function, a constructor
    fn new(width: u32, height: u32) -> Rectangle {
        Rectangle { width, height }
    }

    fn square(size: u32) -> Rectangle {
        Rectangle { width: size, height: size }
    }

    fn area(&self) -> u32 {
        self.width * self.height
    }
}

fn main() {
    let rect = Rectangle::new(3, 4);   // :: because there's no instance yet
    let sq = Rectangle::square(5);
    println!("{} and {}", rect.area(), sq.area());
}

You already know one of these: String::from("hi"). from is an associated function on String — no existing string to act on, so it uses :: and returns a fresh one. new is the conventional name for a constructor, but it’s just a regular associated function, nothing magic.

The pattern to remember: Type::function() when there’s no instance yet (creating one); value.method() when you already have one.

Methods keep code tidy

Why put behavior in an impl instead of loose functions? Because the logic that belongs to a type lives with that type. Anyone reading Rectangle sees everything it can do in one place, and calling rect.area() reads better than area(&rect). It’s the same reason you bundle data into a struct — grouping what belongs together.

Common mistakes

  • Using &self when you need to mutate. If a method changes a field, it must take &mut self. With &self you’ll get “cannot assign to self.x, which is behind a & reference.” Switch the receiver to &mut self.
  • Calling a &mut self method on a non-mut value. let c = Counter { ... }; c.increment(); fails because c isn’t mut. The variable must be let mut c to allow the mutable borrow.
  • Mixing up . and ::. Associated functions (no self) are called with ::: Rectangle::new(...). Methods (with self) are called with a dot: rect.area(). Using the wrong one is a common early error.
  • Accidentally consuming with self. A method that takes self (no &) moves the value; you can’t use the variable afterward. If you only meant to read, use &self so the caller keeps ownership.
  • Forgetting self inside the method. Fields are self.width, not just width. Without self., Rust looks for a local variable named width and won’t find one.

More examples

Reading a thermostat’s setting

A smart-home app needs to display the temperature without ever letting other code accidentally change it — a perfect job for &self.

struct Thermostat {
    celsius: f64,
}

impl Thermostat {
    fn fahrenheit(&self) -> f64 {
        self.celsius * 9.0 / 5.0 + 32.0
    }
}

fn main() {
    let t = Thermostat { celsius: 22.0 };
    println!("{:.1}F", t.fahrenheit());
}

Growing a shopping cart

An online store adds items to a cart every time the shopper clicks “add to cart” — that’s a change, so it needs &mut self.

struct Cart {
    items: Vec<String>,
}

impl Cart {
    fn add_item(&mut self, item: &str) {
        self.items.push(item.to_string());
    }
}

fn main() {
    let mut cart = Cart { items: Vec::new() };
    cart.add_item("keyboard");
    cart.add_item("mouse");
    println!("{:?}", cart.items);
}

Unwrapping a sealed envelope

Once you open a sealed envelope you can’t reseal it — some methods should consume their value and hand back what’s inside, never to be used again.

struct Envelope {
    letter: String,
}

impl Envelope {
    fn open(self) -> String {
        self.letter
    }
}

fn main() {
    let envelope = Envelope { letter: String::from("You got the job!") };
    let letter = envelope.open();
    println!("{letter}");
    // envelope can't be used anymore — it was consumed by open()
}

Building a user profile from parts

A constructor gathers scattered inputs — a name, an age — into one valid struct, so callers never have to build a UserProfile field-by-field.

struct UserProfile {
    name: String,
    age: u32,
}

impl UserProfile {
    fn new(name: &str, age: u32) -> UserProfile {
        UserProfile { name: name.to_string(), age }
    }
}

fn main() {
    let user = UserProfile::new("Priya", 29);
    println!("{} is {}", user.name, user.age);
}

Configuring a server before it starts

Chaining self-consuming methods that each return Self lets you configure an object step by step, like ServerConfig::new().with_port(8080).

struct ServerConfig {
    port: u16,
    debug: bool,
}

impl ServerConfig {
    fn new() -> ServerConfig {
        ServerConfig { port: 80, debug: false }
    }

    fn with_port(mut self, port: u16) -> ServerConfig {
        self.port = port;
        self
    }

    fn with_debug(mut self, debug: bool) -> ServerConfig {
        self.debug = debug;
        self
    }
}

fn main() {
    let config = ServerConfig::new().with_port(8080).with_debug(true);
    println!("port={} debug={}", config.port, config.debug);
}

Your turn

This program defines a BankAccount with a deposit method, but it won’t compile. The deposit method can’t change the balance, and the account it’s called on isn’t declared right. Fix both. Press ▶ Run.

struct BankAccount {
    balance: u32,
}

impl BankAccount {
    fn deposit(&self, amount: u32) {
        self.balance += amount;
    }
}

fn main() {
    let account = BankAccount { balance: 100 };
    account.deposit(50);
    println!("balance is {}", account.balance);
}
Show solution

deposit changes a field, so it needs &mut self. And to call a &mut self method, account must be declared mut.

struct BankAccount {
    balance: u32,
}

impl BankAccount {
    fn deposit(&mut self, amount: u32) {
        self.balance += amount;
    }
}

fn main() {
    let mut account = BankAccount { balance: 100 };
    account.deposit(50);
    println!("balance is {}", account.balance); // 150
}

The &mut self lets the method modify balance, and let mut account allows the mutable borrow.

Quick check

Remember this

  • Methods live in an impl Type { ... } block and take a receiver as their first parameter.
  • &self borrows to read (most methods), &mut self borrows to change, self consumes the value (rare).
  • Calling a &mut self method requires the variable to be mut.
  • Associated functions have no self (often constructors like new) and are called with Type::function().
  • Method calls use a dot (value.method()); associated functions use :: (Type::func()). String::from is a familiar example.

Go deeper

Next:

Ownership

Intermediate · Ownership

What & why

Ownership is the idea that makes Rust different. It’s how Rust keeps your program’s memory safe without a garbage collector and without you calling free() by hand. It feels strange for a few days, and then it clicks and the rest of the language suddenly makes sense. This is the lesson worth going slow on.

The idea, slowly

The problem every language has to solve

Your program uses memory to hold values — a string, a list, a picture. At some point that memory has to be given back, or your program leaks and slowly eats the machine. Languages solve this in different ways:

  • Some (Python, JavaScript, Java) run a garbage collector: a background process that occasionally pauses your program and cleans up. Easy for you, but it costs speed and control.
  • Some (C, C++) make you free memory by hand. Fast, but forget once and you get crashes and security holes.

Rust picks a third path: ownership rules that the compiler checks for you, before the program ever runs. No pauses, no manual freeing, no leaks. The catch is you have to learn the rules.

The three rules

  1. Every value has exactly one owner (a variable that owns it).
  2. There can only be one owner at a time.
  3. When the owner goes out of scope (its { } block ends), the value is dropped — its memory is freed automatically.

Think of a value like a physical object and the owner like the person holding it. Only one person holds it at a time. When that person leaves the room, the object is thrown away.

“Move”: handing the object over

Watch what happens when you assign one variable to another:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;               // the value MOVES from s1 to s2
    println!("{}", s2);        // fine — s2 owns it now
    // println!("{}", s1);     // ERROR if you uncomment: s1 no longer owns anything
}

let s2 = s1; does not make a copy of the string. It moves ownership from s1 to s2. After the move, s1 is empty — using it is a compile error. Rust does this so two variables can never both think they own (and both try to free) the same memory.

Uncomment the s1 line and press Run. Read the error. The compiler literally says value borrowed here after move. That message is your friend — it’s Rust catching a bug for you at compile time instead of at 2am in production.

Why doesn’t this happen with numbers?

fn main() {
    let x = 5;
    let y = x;                 // x is COPIED, not moved
    println!("x = {}, y = {}", x, y);  // both work fine!
}

Small, fixed-size values like integers implement a trait called Copy. They’re so cheap to duplicate that Rust just copies them instead of moving. So x is still usable. The rule of thumb: simple stack values (numbers, bool, char) copy; things that own heap data (like String, Vec) move.

Moving into a function

Passing a value to a function moves it too, unless it’s a Copy type:

fn main() {
    let s = String::from("hi");
    takes_it(s);               // s is moved INTO the function
    // println!("{}", s);      // ERROR: s was moved away
}

fn takes_it(text: String) {
    println!("got: {}", text);
} // text goes out of scope here and the String is dropped

This is annoying at first — “I just want to use the string, not give it away!” That’s exactly what the next lesson, borrowing, is for: a way to lend a value without giving up ownership.

Common mistakes

  • Thinking assignment copies. For String, Vec, and most types, let b = a; moves. a is gone afterward. Don’t assume everything behaves like a number.
  • “Use after move” errors. If the compiler says a value was “moved,” you tried to use a variable after its value went somewhere else. The fix is usually to borrow (next lesson) or to .clone() if you really do want a separate copy.
  • Reaching for .clone() too fast. Cloning works but makes a full copy every time. Fine while learning; later you’ll prefer borrowing to avoid the cost.

More examples

Round-tripping a string through a function

Sometimes a function needs to transform a String and hand it right back, rather than just borrowing it — useful as one step in a small text-processing pipeline.

fn shout(mut text: String) -> String {
    text.push('!');
    text.to_uppercase()
}

fn main() {
    let message = String::from("hello");
    let message = shout(message); // ownership goes in, comes back out
    println!("{message}");
}

Moving a Vec into a background thread

Spawning a worker thread to crunch a batch of numbers means handing it full ownership of that data — the main thread can’t be trusted to keep using it while another thread works on it.

use std::thread;

fn main() {
    let numbers = vec![1, 2, 3, 4, 5];

    let handle = thread::spawn(move || {
        let total: i32 = numbers.iter().sum();
        println!("sum computed on another thread: {total}");
    });

    handle.join().unwrap();
    // numbers is gone here — it was moved into the closure
}

Ownership transfer through a struct field

Placing an order takes ownership of the customer’s shipping address — the struct becomes the new home for that String, and it moves along with the order.

struct Order {
    item: String,
    shipping_address: String,
}

fn main() {
    let address = String::from("221B Baker Street");
    let order = Order {
        item: String::from("teapot"),
        shipping_address: address, // address moves into the struct
    };
    println!("Shipping {} to {}", order.item, order.shipping_address);
}

Cloning when you genuinely need two independent copies

A template config should stay untouched while you customize a copy for a specific environment — cloning gives you two Vecs that can each change independently.

fn main() {
    let template = vec![String::from("debug=false"), String::from("port=80")];
    let mut staging = template.clone();
    staging.push(String::from("env=staging"));

    println!("template: {:?}", template); // untouched
    println!("staging: {:?}", staging);   // has the extra line
}

Your turn

This program doesn’t compile — it uses s after moving it into greet. Fix it so it prints the greeting and the length 5, without removing either println!. (Hint: one small .clone(), or think about what you learned — borrowing is coming in the next lesson.)

fn main() {
    let s = String::from("hello");
    greet(s);
    println!("the word was {} letters", s.len()); // error: s was moved
}

fn greet(word: String) {
    println!("Hi, {}!", word);
}
Show solution

The quickest fix while you’re still learning is to give greet its own clone, leaving the original s untouched:

fn main() {
    let s = String::from("hello");
    greet(s.clone());          // hand over a copy
    println!("the word was {} letters", s.len()); // s is still ours
}

fn greet(word: String) {
    println!("Hi, {}!", word);
}

The better fix (once you finish the Borrowing lesson) is to lend a reference with & so nothing moves at all: greet(&s) and fn greet(word: &String). No clone, no cost.

Quick check

Remember this

  • Each value has exactly one owner; there’s only one owner at a time.
  • When the owner’s scope ends, the value is dropped (memory freed) automatically.
  • Assigning or passing an owning type (String, Vec, …) moves it; the old variable can’t be used afterward.
  • Simple Copy types (numbers, bool, char) are copied instead of moved.
  • To use a value without giving it away, borrow it — that’s the next lesson.

Go deeper

Next:

Borrowing

Intermediate · Ownership

What & why

The last lesson ended on a frustrating note: passing a String into a function gives it away, and then you can’t use it anymore. Borrowing is the fix. It lets you lend a value to a function so it can look at it (or even change it) and then hand it back — no giving away, no copying, no cost. This is the “lend without giving away” idea from the Ownership lesson, made real.

The idea, slowly

The problem borrowing solves

Remember this from the last lesson? Passing s into a function moved it, and the line after broke:

fn main() {
    let s = String::from("hello");
    greet(s);                         // s is MOVED into greet
    // println!("{}", s.len());       // ERROR: s was given away
}

fn greet(word: String) {
    println!("Hi, {}!", word);
}

That’s a lot of ceremony just to look at a string. In real life, if a friend wants to read your book, you don’t sign the book over to them forever — you lend it, they read it, they give it back. Rust has exactly that: a reference, written with an ampersand &.

Lending with &

A reference is a way to say “let this function use my value without taking ownership of it.” You create one by putting & in front of the value, and the function says it wants one by putting & in front of the type:

fn main() {
    let s = String::from("hello");
    greet(&s);                                    // lend s (don't give it away)
    println!("the word was {} letters", s.len()); // s is STILL OURS — works!
}

fn greet(word: &String) {                          // "word" is a reference, not owned
    println!("Hi, {}!", word);
}

Run this. It prints the greeting and the length. Nothing moved. That’s the whole point of borrowing: &s hands the function a reference to the string, s keeps ownership, and after greet finishes you can keep using s normally.

The act of making and using a reference is called borrowing. You “borrow” the value, the same way your friend borrows the book. And just like a borrowed book, there are rules about what you’re allowed to do with something you don’t own.

What the compiler is thinking

When the function takes &String, the compiler thinks: “This function is only borrowing. It does not own this string, so when the function ends, it must NOT free the memory — the real owner back in main is still using it.” When the function takes a plain String, the compiler thinks the opposite: “This function now owns it; when the function ends, drop it.” That one little & is what tells Rust which of those two stories is true.

Read-only by default: &

A plain & borrow is read-only. You can look, but you can’t change:

fn main() {
    let s = String::from("hello");
    let len = measure(&s);
    println!("{} is {} chars", s, len);
}

fn measure(word: &String) -> usize {
    word.len()          // reading is fine
    // word.push('!');  // ERROR: can't change a value you only borrowed read-only
}

This is like borrowing a library book: you may read it, but you may not scribble in it. If you try to change it, the compiler stops you with cannot borrow ... as mutable.

When you DO want to change it: &mut

Sometimes you want the function to change your value — say, add an exclamation mark. For that you need a mutable borrow, written &mut. Three things all have to line up:

  1. The original variable must be declared mut (it has to be changeable in the first place).
  2. You pass it with &mut.
  3. The function accepts &mut.
fn main() {
    let mut s = String::from("hello");   // 1. must be mut
    add_excitement(&mut s);              // 2. lend it mutably
    println!("{}", s);                   // prints: hello!
}

fn add_excitement(word: &mut String) {   // 3. accepts &mut
    word.push('!');                      // now changing it is allowed
}

The value is still owned by main the whole time. We only lent the right to change it for the duration of the call, then took it back. This is like lending your friend a pencil-and-paper form and saying “go ahead, fill it in” — they modify your thing, but it’s still yours.

The one big rule: one writer, or many readers

Here’s the rule that trips everyone up, so read it slowly. At any given moment, for one value, you can have either:

  • any number of read-only (&) borrows — many readers are fine, OR
  • exactly one mutable (&mut) borrow — one writer, and nobody else.

You can never have a &mut at the same time as any other borrow. Why? Imagine one part of your code is reading a list while another part is deleting items from it — the reader would see garbage. Rust forbids that situation at compile time so it can never happen while the program runs.

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

    let r1 = &s;      // reader 1
    let r2 = &s;      // reader 2 — fine, many readers allowed
    println!("{} and {}", r1, r2);   // last use of r1 and r2

    let w = &mut s;   // now a writer — allowed, because r1/r2 are done being used
    w.push('!');
    println!("{}", w);
}

Think of it as a shared document: lots of people can read it at the same time, but the moment someone wants to edit, everyone else has to step away. Rust enforces this so your data can never change underneath you while you’re looking at it.

Common mistakes

  • Forgetting & on both sides. If the value is &s but the function still says word: String, or vice versa, the types don’t match and you get mismatched types: expected String, found &String. The & has to be on the value and on the parameter type.
  • Trying to mutate through a plain & borrow. A read-only borrow can’t call methods that change the value (like .push). The error is cannot borrow ... as mutable, as it is behind a & reference. Fix: use &mut everywhere and make the original variable mut.
  • A &mut while another borrow is alive. cannot borrow ... as mutable because it is also borrowed as immutable means you still have a reader hanging around. The fix is usually to stop using the earlier reference before you start the mutable one.
  • Forgetting mut on the variable itself. You can’t take a &mut of something that was never declared mut. The error points you back to the let and says to add mut.

More examples

Reading a cart’s size without taking it

A dashboard needs to show how many items are in the cart without taking the cart away from the checkout logic that still needs it.

fn summarize(items: &Vec<String>) -> usize {
    items.len()
}

fn main() {
    let cart = vec![String::from("pen"), String::from("notebook")];
    println!("{} items in cart", summarize(&cart));
    println!("still have it: {:?}", cart);
}

Restocking a shelf through a mutable borrow

A warehouse restock function needs to add new items to an existing inventory list without taking ownership of the whole warehouse.

fn restock(inventory: &mut Vec<&str>) {
    inventory.push("stapler");
}

fn main() {
    let mut inventory = vec!["paper", "pens"];
    restock(&mut inventory);
    println!("{:?}", inventory);
}

Doubling every score in place

A game engine wants to apply a 2x multiplier to every player’s score after a bonus round, editing the list it was given rather than building a new one.

fn double_all(scores: &mut Vec<i32>) {
    for score in scores.iter_mut() {
        *score *= 2;
    }
}

fn main() {
    let mut scores = vec![10, 20, 30];
    double_all(&mut scores);
    println!("{:?}", scores);
}

Why two &mut borrows can’t coexist

Imagine two parts of a program both trying to hand out edit access to the same balance at once — Rust catches that at compile time before it becomes a real bug.

fn main() {
    let mut balance = 100;

    let r1 = &mut balance;
    let r2 = &mut balance; // ERROR: second mutable borrow while r1 is still alive

    println!("{} {}", r1, r2);
}

Borrowing one field while another stays free

A player struct has a name (read for the scoreboard) and a score (updated after each round). Rust lets you borrow each field independently at the same time, since they don’t overlap.

struct Player {
    name: String,
    score: u32,
}

fn main() {
    let mut player = Player { name: String::from("Kai"), score: 0 };

    let name_ref = &player.name;       // borrow just the name field
    let score_ref = &mut player.score; // borrow just the score field, mutably
    *score_ref += 10;

    println!("{name_ref} now has {score_ref} points");
}

Your turn

This program wants to add a "." to the end of the sentence, then print it. It doesn’t compile. Fix it so it prints learning rust. (Hint: three things have to line up for a mutable borrow.)

fn main() {
    let sentence = String::from("learning rust");
    finish(sentence);
    println!("{}", sentence);
}

fn finish(text: &String) {
    text.push('.');
}
Show solution

Two problems: the function takes ownership (plain String) but we need it back, and it tries to change a read-only borrow. Switch everything to a mutable borrow and make sentence mutable:

fn main() {
    let mut sentence = String::from("learning rust");  // must be mut
    finish(&mut sentence);                             // lend it mutably
    println!("{}", sentence);                          // still ours — prints: learning rust.
}

fn finish(text: &mut String) {   // accept a mutable borrow
    text.push('.');              // now allowed to change it
}

The &mut lets finish change the string in place, and because it only borrowed, sentence still belongs to main afterward.

Quick check

Remember this

  • A reference (&) lets a function use a value without taking ownership — it borrows, then gives it back.
  • Plain & is read-only; &mut lets you change the value (and needs the original to be mut).
  • The rule: at one time you may have many readers or one writer, never both.
  • Borrowing costs nothing and moves nothing — reach for it before you reach for .clone().

Go deeper

Next:

References and dereference

Intermediate · Ownership

What & why

In the Borrowing lesson you used & to lend values around. This lesson slows down and looks at what a reference actually is, and introduces its partner symbol * (dereference) — the way to reach through a reference to touch the value on the other end. Once these two clicks, the & and * you see everywhere in Rust stop looking like magic.

The idea, slowly

A reference is a signpost

A reference doesn’t contain the value. It points at the value, the way a signpost doesn’t contain a town — it just points to where the town is. When you write &value, you make a signpost that says “the real thing is over there.”

fn main() {
    let x = 10;
    let r = &x;        // r is a reference — a signpost pointing at x
    println!("x is {}", x);
    println!("r points at {}", r);   // Rust follows the signpost for you when printing
}

Both lines print 10. x is the value; r is a signpost to it. Notice you didn’t need any special symbol to print through rprintln! is polite and follows the signpost automatically. But not everything does, and that’s where * comes in.

* follows the signpost

* means “go to where this reference points and give me the actual value there.” It’s called dereferencing — literally “un-referencing,” reaching through the pointer.

Watch what happens with arithmetic, where Rust will not silently follow the signpost:

fn main() {
    let x = 10;
    let r = &x;

    // println!("{}", r + 1);   // ERROR: r is a signpost, not a number
    println!("{}", *r + 1);     // *r follows the signpost to get 10, then + 1 = 11
}

r by itself is a reference (a &i32), and you can’t add 1 to a signpost. *r says “follow it, get the 10,” and then + 1 works. The mental move is: & makes a reference, * follows it back to the value. They are opposites.

Changing a value through a &mut reference

Dereferencing really earns its keep with mutable references. To change the value a &mut points at, you dereference with * and assign:

fn main() {
    let mut count = 5;
    let r = &mut count;   // a mutable signpost to count

    *r += 1;              // follow the signpost, add 1 to the real value

    println!("{}", count);   // prints 6
}

*r += 1 reads as: “go to where r points (that’s count) and add 1 there.” Without the *, you’d be trying to add 1 to the signpost itself, which is meaningless — and the compiler says so.

Why do methods like .len() not need *?

You may have noticed that in the Borrowing lesson you called word.len() on a &String and never wrote a *. That’s because Rust does a helpful automatic step called deref coercion: when you call a method with ., Rust will quietly follow references for you as many times as needed to find the method. So word.len() works whether word is a String or a &String or even a &&String.

fn main() {
    let s = String::from("atlas");
    let r = &s;

    println!("{}", s.len());   // 5
    println!("{}", r.len());   // 5 — Rust auto-follows the reference for the method call
}

The rule of thumb: the dot operator (.) follows references for you automatically; bare operators like +, +=, and == do not. So you mostly need * for arithmetic and assignment through a reference, and rarely for method calls.

&str vs &String: a tiny preview

You’ll often see &str where you might expect &String. A &str is a reference to string text — a very common, lightweight “view” of characters. Because of deref coercion, a &String can be used almost anywhere a &str is wanted, so this just works:

fn main() {
    let owned = String::from("hello");
    shout(&owned);            // &String is accepted where &str is asked for
}

fn shout(text: &str) {        // prefer &str for read-only text parameters
    println!("{}!", text.to_uppercase());
}

Don’t worry about mastering &str yet — the Slices lesson (next) explains exactly what it is. For now just know: writing your read-only text parameters as &str makes your functions accept more kinds of callers, and you can pass a &String right in.

Common mistakes

  • Using a reference where a value is needed. Writing r + 1 when r is &i32 gives cannot add {integer} to &{integer}. You forgot to dereference — use *r + 1.
  • Adding * where the . already handles it. You rarely need (*r).len(); just write r.len(). Over-dereferencing is a common beginner habit — let the dot do its job.
  • Trying *r = ... through a read-only &. You can only assign through a &mut. Assigning through a plain & gives cannot assign to ... behind a & reference. Make it &mut.
  • Confusing & and * directions. & creates a reference (value → signpost); * follows one (signpost → value). If a line feels backwards, check which direction you actually want.

More examples

Comparing two prices through references

A price-comparison tool receives two prices by reference (so it doesn’t have to own them) and needs to check whether they’re equal.

fn same_price(a: &f64, b: &f64) -> bool {
    *a == *b
}

fn main() {
    let price1 = 19.99;
    let price2 = 19.99;
    println!("{}", same_price(&price1, &price2));
}

Returning a reference derived from a parameter

A leaderboard function wants to hand back a reference to the top entry without copying the whole list.

fn first_entry(scores: &Vec<i32>) -> &i32 {
    &scores[0]
}

fn main() {
    let scores = vec![99, 87, 65];
    println!("top score: {}", first_entry(&scores));
}

Auto-deref through multiple reference layers

Passing a reference to a reference around (common when values get threaded through iterators or nested calls) still lets you call methods normally — Rust peels off as many layers as it needs.

fn main() {
    let x: i32 = 5;
    let r = &x;
    let rr = &r; // rr is a &&i32

    println!("{}", rr.pow(2)); // Rust auto-derefs &&i32 -> &i32 -> i32 to find pow
}

Bumping a retry counter through a mutable reference

A network client tracks how many times it has retried a request, and the retry function only gets a &mut i32 — not ownership — so it must dereference to change it.

fn record_retry(attempts: &mut i32) {
    *attempts += 1;
}

fn main() {
    let mut attempts = 0;
    record_retry(&mut attempts);
    record_retry(&mut attempts);
    println!("retried {} times", attempts);
}

Swapping two values through mutable references

Keeping a scoreboard’s two top entries in descending order means occasionally swapping them in place, touching nothing but the two numbers themselves.

fn swap_if_out_of_order(a: &mut i32, b: &mut i32) {
    if *a < *b {
        let temp = *a;
        *a = *b;
        *b = temp;
    }
}

fn main() {
    let mut first = 10;
    let mut second = 42;
    swap_if_out_of_order(&mut first, &mut second);
    println!("{first} {second}");
}

Your turn

This program tries to double a number through a mutable reference, but it doesn’t compile. Fix it so it prints 8.

fn main() {
    let mut n = 4;
    let r = &mut n;
    r = r * 2;
    println!("{}", n);
}
Show solution

r is a signpost, not a number, so r * 2 is meaningless and r = ... tries to point the signpost somewhere new instead of changing the value. Dereference with * to reach the real value and change it there:

fn main() {
    let mut n = 4;
    let r = &mut n;
    *r = *r * 2;      // follow the signpost on both sides: n becomes 4 * 2
    println!("{}", n); // prints 8
}

*r on the right reads the current value (4), and *r = on the left writes the new value back into n. You could also write it as *r *= 2;.

Quick check

Remember this

  • A reference (&) is a signpost that points at a value; it doesn’t hold the value itself.
  • * dereferences — it follows the signpost back to the actual value.
  • & and * are opposites: one makes a reference, the other follows it.
  • The dot operator (.) auto-follows references for method calls; bare operators like + and = need you to write * yourself.

Go deeper

Next:

Slices

Intermediate · Ownership

What & why

A slice is a borrowed view into part of a collection — a piece of a string or an array — without copying it and without owning it. It’s how you say “just this middle chunk” and pass it around cheaply. Slices are everywhere in Rust, and they’re the thing the mysterious &str type actually is.

The idea, slowly

A window onto a row of boxes

Picture a String as a row of labelled boxes, one per byte of text:

 h   e   l   l   o
 0   1   2   3   4

A slice is a window you slide over that row to see just some of the boxes — say boxes 0, 1, 2. The window doesn’t copy the boxes and doesn’t own them; it just says “look here, from this box up to that box.” You make one with &thing[start..end]:

fn main() {
    let s = String::from("hello");
    let hi = &s[0..2];    // a window over boxes 0 and 1: "he"
    let lo = &s[2..5];    // boxes 2,3,4: "llo"
    println!("{} + {}", hi, lo);   // he + llo
}

The range 0..2 means start at 0, stop before 2 — so it includes 0 and 1, not 2. That “stop before the end number” rule is the one to burn into memory; it’s the same everywhere ranges appear in Rust.

The & matters: a slice is a borrow

Notice the & in &s[0..2]. A slice is a kind of reference — a borrow — so all the borrowing rules from two lessons ago still apply. The slice points into s’s memory; it does not own it. That’s why it’s cheap (nothing is copied) and why it can’t outlive s (more on that in the mistakes section).

Handy shortcuts for the ends

You can leave off a number to mean “the very start” or “the very end”:

fn main() {
    let s = String::from("hello");
    println!("{}", &s[..2]);    // from the start up to 2  -> "he"
    println!("{}", &s[2..]);    // from 2 to the end       -> "llo"
    println!("{}", &s[..]);     // the whole thing         -> "hello"
}

&s[..] (the whole string as a slice) is common when a function wants a slice and you have the whole string.

Meet &str: the string slice

Here’s the payoff. That &str type you keep seeing? A &str is a string slice — a borrowed window into string text. When you slice a String, the result is a &str. And a plain text literal like "hello" in your code is also a &str (it’s a window into text baked into your program). So these are the same type:

fn main() {
    let owned = String::from("hello world");
    let piece: &str = &owned[0..5];   // "hello", a slice of the String
    let literal: &str = "hello";      // also a &str, baked into the program

    println!("{} == {} is {}", piece, literal, piece == literal);  // true
}

This is why the last lesson said to write read-only text parameters as &str: it accepts both literals and slices of Strings (and, thanks to deref coercion, whole &Strings too). One parameter type, lots of callers.

A real reason to slice: return part of a string

Say you want the first word of a sentence. Instead of copying characters into a new String, you return a slice — a window onto the original text. No allocation, no copy:

fn main() {
    let sentence = String::from("learning rust today");
    let word = first_word(&sentence);
    println!("first word: {}", word);   // learning
}

fn first_word(s: &str) -> &str {
    for (i, ch) in s.char_indices() {
        if ch == ' ' {
            return &s[..i];    // window from start up to the first space
        }
    }
    s                          // no space found: the whole thing is one word
}

first_word returns a &str that borrows from sentence — a view, not a copy. This is the classic example of why slices exist.

Slices work on arrays too

Slices aren’t just for strings. Any array or Vec can be sliced the same way, giving a &[T] (a borrowed window over a sequence of T):

fn main() {
    let numbers = [10, 20, 30, 40, 50];
    let middle = &numbers[1..4];   // a window over 20, 30, 40
    println!("{:?}", middle);      // [20, 30, 40]
    println!("sum = {}", middle.iter().sum::<i32>());   // 90
}

Same idea, same start..end rule, same “it’s a borrow” behavior. Whenever a function only needs to read a run of items, taking a slice (&[T] or &str) is the idiomatic choice — it works whether the caller has an array, a Vec, or another slice.

Common mistakes

  • Off-by-one from the exclusive end. &s[0..2] gives you indices 0 and 1, not 2. Forgetting that the end is exclusive is the #1 slice bug. [..2] = “the first two.”
  • Slicing a string in the middle of a character. Rust strings are UTF-8 bytes, and some characters (like é or emoji) take more than one byte. Slicing between the bytes of one character panics at runtime with byte index ... is not a char boundary. For plain ASCII (a-z, 0-9) every character is one byte, so you’re safe; just be careful with accented or non-Latin text.
  • Index out of range. &s[0..99] on a 5-byte string panics with byte index 99 is out of range. A slice can’t point past the end of what it borrows.
  • Letting the owner die first. A slice borrows from a value, so it can’t outlive that value. If you drop or move the original String while a slice of it is still in use, the compiler stops you — the window would be pointing at boxes that no longer exist.

More examples

Grabbing the middle innings of a game log

A sports app stores scores for every inning but only wants to show innings 3 through 5 without copying the whole game.

fn main() {
    let innings = vec![1, 0, 2, 3, 0, 1, 4];
    let middle = &innings[2..5];
    println!("{:?}", middle);
}

A function that works with arrays and Vecs alike

A stats helper shouldn’t care whether the caller has a fixed-size array or a growable Vec — accepting a slice lets it work with both.

fn average(nums: &[f64]) -> f64 {
    nums.iter().sum::<f64>() / nums.len() as f64
}

fn main() {
    let fixed = [1.0, 2.0, 3.0];
    let growable = vec![4.0, 5.0, 6.0, 7.0];
    println!("{}", average(&fixed));
    println!("{}", average(&growable));
}

Splitting a sentence into words

A search box wants each word a user typed on its own, so it can match them individually against an index.

fn main() {
    let query = String::from("best rust books for beginners");
    let words: Vec<&str> = query.split_whitespace().collect();
    println!("{:?}", words);
}

Splitting a buffer into two mutable halves

A packet buffer needs its header and payload processed separately, in place, without copying — split_at_mut hands back two non-overlapping mutable slices.

fn main() {
    let mut buffer = [1, 2, 3, 4, 5, 6];
    let (header, payload) = buffer.split_at_mut(2);
    header[0] = 99;
    payload[0] = 42;
    println!("header: {:?}, payload: {:?}", header, payload);
}

Dealing a deck into hands

A card game needs to split a shuffled deck into equal-sized hands without allocating a new Vec for each one.

fn main() {
    let deck: Vec<i32> = (1..=10).collect();
    let hands: Vec<&[i32]> = deck.chunks(5).collect();
    for hand in &hands {
        println!("{:?}", hand);
    }
}

Your turn

This program wants to print the first three letters, rus. It doesn’t compile. Fix it.

fn main() {
    let word = String::from("rust");
    let start = word[0..3];
    println!("{}", start);
}
Show solution

A slice is a borrow, so it needs the &. Without it, you’re asking to move a chunk of the string out by value, which isn’t allowed. Add &:

fn main() {
    let word = String::from("rust");
    let start = &word[0..3];    // a borrowed window: "rus"
    println!("{}", start);
}

&word[0..3] makes a &str viewing bytes 0, 1, 2 — the letters r, u, s — without copying or taking ownership.

Quick check

Remember this

  • A slice is a borrowed view into part of a collection — no copy, no ownership.
  • Make one with &thing[start..end]; the end index is exclusive (stops before it).
  • &str is a string slice — that’s why text literals and slices of a String share the type.
  • Slices work on arrays and Vecs too, giving &[T]; prefer slice parameters for read-only access.
  • A slice can’t outlive the value it borrows from.

Go deeper

Next:

Lifetimes

Intermediate · Ownership

What & why

A lifetime is Rust’s way of tracking how long a reference stays valid — how long the thing it points at is guaranteed to exist. Most of the time Rust figures this out silently and you never write a lifetime at all. This lesson is about the handful of times you do have to write one, so that when you meet the strange-looking 'a syntax it feels ordinary instead of terrifying.

The idea, slowly

The danger lifetimes protect against

Think back to slices and references: a reference is a signpost pointing at a value it doesn’t own. Now imagine the value gets thrown away while the signpost still exists. The signpost would point at nothing — an empty lot where the house used to be. Reading through it would be a serious bug (in C this is the infamous “dangling pointer”; it causes crashes and security holes).

Rust makes that impossible. This does not compile:

fn main() {
    let r;
    {
        let value = 42;
        r = &value;        // r points at value...
    }                      // ...but value is dropped HERE, at the end of the block
    // println!("{}", r);  // ERROR: r would point at something that no longer exists
}

The compiler says value does not live long enough. It has been tracking, for every reference, the “lifetime” of the thing it points at, and it noticed r tries to outlive value. That tracking is what lifetimes are. Usually it happens completely behind the scenes.

Lifetimes are a description, not a command

Here is the single most important sentence in this lesson: a lifetime annotation does not make anything live longer. It only describes a relationship that already exists. Writing 'a is like labelling two boxes “these go together” — it doesn’t create anything, it just tells the compiler how the pieces relate so it can check them. If you remember nothing else, remember that.

When Rust needs your help

Rust can figure lifetimes out on its own almost always. The exception is when a function returns a reference and there’s more than one reference coming in — because then Rust can’t tell which input the output borrows from. Look at this function that returns the longer of two strings:

fn main() {
    let a = String::from("short");
    let b = String::from("a longer one");
    let result = longest(&a, &b);
    println!("longest is: {}", result);
}

fn longest<'a>(left: &'a str, right: &'a str) -> &'a str {
    if left.len() > right.len() {
        left
    } else {
        right
    }
}

The returned &str borrows from either left or right — the compiler genuinely cannot tell which, because it depends on the lengths at runtime. So it asks you to spell out the relationship. That’s what the 'as do.

Reading the 'a syntax out loud

Let’s decode fn longest<'a>(left: &'a str, right: &'a str) -> &'a str slowly:

  • <'a> — “I’m introducing a lifetime name called a.” The apostrophe is just Rust’s way of writing lifetime names; 'a is read “tick-a.” It’s a made-up label, like a variable name. You could call it 'thing, but everyone uses short names like 'a.
  • left: &'a str — “left is a reference that lives at least as long as 'a.”
  • right: &'a str — “right also lives at least as long as 'a.”
  • -> &'a str — “the reference I return also lives as long as 'a.”

Put together, you’re telling the compiler: “The thing I hand back borrows from these inputs, so it’s only valid for as long as both of them are valid.” Now the compiler has enough information to check every call and reject any that would keep the result alive too long.

Seeing it catch a real bug

Because you told the compiler the result borrows from the inputs, it can now stop you from misusing it. This does not compile, and that’s a good thing:

fn main() {
    let a = String::from("short");
    let result;
    {
        let b = String::from("a longer one");
        result = longest(&a, &b);   // result might borrow from b
    }                               // b is dropped here
    // println!("{}", result);      // ERROR: result could be pointing at dropped b
}

fn longest<'a>(left: &'a str, right: &'a str) -> &'a str {
    if left.len() > right.len() { left } else { right }
}

Without the lifetime, Rust couldn’t have known result might depend on b. With it, the compiler connects the dots and refuses to let result outlive b. The 'a didn’t change how the program runs — it gave the compiler the information to catch the mistake.

Why you’ve rarely seen this before

You wrote functions with references for two whole lessons without a single 'a. That’s because Rust has built-in shortcuts (called elision rules) that fill lifetimes in automatically for the common, unambiguous cases — like a function taking one reference and returning one. You only reach for explicit 'a when the relationship is genuinely ambiguous, as with two inputs and a borrowed output. So: don’t go sprinkling 'a everywhere. Write your code normally, and add lifetimes only when the compiler asks — its error message will even suggest the annotation.

Common mistakes

  • Thinking 'a extends how long data lives. It does not allocate or prolong anything. If your data is dropped too early, adding lifetimes won’t save it — you have to restructure so the data lives long enough (e.g. return an owned String instead of a borrow).
  • Adding lifetimes before the compiler asks. Rust’s elision handles most cases. Annotating everything by hand is noise and often wrong. Write it plain first; add 'a only when you see missing lifetime specifier.
  • Returning a reference to a local variable. A function can’t return a reference to something it created inside itself — that local is dropped when the function ends. The error is cannot return reference to local variable. The fix is usually to return the owned value (String) instead of a &str.
  • Reading 'a as a type. 'a is not a type like i32; it’s a label for a duration. It goes in the <...> alongside generic type parameters but means “how long,” not “what kind.”

More examples

A struct holding two independently-lived references

A search result might pair a snippet from a document with a highlighted term from a completely separate query string — the two don’t have to share a lifespan.

struct Match<'a, 'b> {
    snippet: &'a str,
    term: &'b str,
}

fn main() {
    let document = String::from("Rust makes systems programming approachable");
    let query = String::from("systems");
    let m = Match { snippet: &document[5..], term: &query };
    println!("found '{}' in: {}", m.term, m.snippet);
}

Picking a display name, with a tie-break rule

A profile page shows the longer of a user’s nickname or real name, but on a tie it should prefer the nickname — deepening the classic “return the longer slice” example with real logic.

fn pick_display_name<'a>(nickname: &'a str, real_name: &'a str) -> &'a str {
    if nickname.len() >= real_name.len() {
        nickname
    } else {
        real_name
    }
}

fn main() {
    let nickname = String::from("Kai");
    let real_name = String::from("Kai Anderson");
    println!("{}", pick_display_name(&nickname, &real_name));
}

A generic function with a lifetime bound

Finding the largest item in a list of scores or prices works the same way regardless of the item type, as long as you can compare them — and the result still borrows from the original list.

fn largest<'a, T: PartialOrd>(items: &'a [T]) -> &'a T {
    let mut max = &items[0];
    for item in items {
        if item > max {
            max = item;
        }
    }
    max
}

fn main() {
    let prices = [12.5, 8.0, 21.75, 15.0];
    println!("highest price: {}", largest(&prices));
}

Why returning a reference to a local variable fails

This is the shape of the mistake lifetimes exist to prevent — shown here as a description, not code you can run:

#![allow(unused)]
fn main() {
fn make_greeting(name: &str) -> &str {
    let greeting = format!("Hello, {name}!"); // greeting is LOCAL to this function
    &greeting
    // ERROR: cannot return reference to local variable `greeting`.
    // `greeting` is dropped the instant this function ends, so any reference
    // to it would dangle immediately. No lifetime annotation can fix this —
    // the real fix is to return the owned `String` itself (drop the `&`).
}
}

Your turn

This function is supposed to return the first of two string slices, but it won’t compile. The compiler says it’s missing lifetime specifier. Add what it needs.

fn main() {
    let a = String::from("first");
    let b = String::from("second");
    println!("{}", pick(&a, &b));
}

fn pick(x: &str, y: &str) -> &str {
    x
}
Show solution

The function returns a reference but takes two, so Rust can’t tell which one the output borrows from. Introduce a lifetime 'a and tie the inputs and the output together:

fn main() {
    let a = String::from("first");
    let b = String::from("second");
    println!("{}", pick(&a, &b));
}

fn pick<'a>(x: &'a str, y: &'a str) -> &'a str {
    x
}

The 'a tells the compiler the returned reference lives as long as the inputs, so it can safely check every call. (Even though this function only ever returns x, Rust wants the relationship spelled out because both parameters share the annotation.)

Quick check

Remember this

  • A lifetime tracks how long a reference is valid — Rust uses it to forbid dangling references.
  • A lifetime annotation describes a relationship; it never makes data live longer.
  • You mostly need explicit 'a only when a function returns a reference and takes more than one.
  • 'a is a label for a duration, written in <...>, read “tick-a.”
  • Write code plainly first and add lifetimes only when the compiler asks for them.

Go deeper

Next:

Lifetime elision and ’static

Advanced · Ownership

What & why

Lifetimes are the compiler’s sticky notes for references: “this borrow is only good for as long as X.” Most functions that take and return references never write one down, because three simple rules cover the shapes that show up constantly — the compiler fills in the sticky note itself. This page covers those rules, what happens when a struct needs to hold a reference, and the much-misunderstood 'static — which means “can live for the whole program,” not “is a global.”

The idea, slowly

A quick reminder of why lifetimes exist

A reference can never outlive the data it points to — that’s the core rule the borrow checker enforces. Sometimes a function’s signature needs to describe how an output reference relates to the input references it came from:

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

fn main() {
    let s1 = String::from("hello");
    let s2 = String::from("hi");
    println!("{}", longest(&s1, &s2));
}

<'a> here says: “the returned reference lives no longer than the shorter of a and b’s lifetimes” — the compiler needs that written down because there are two input references, and it can’t guess which one the output borrows from.

The three elision rules

Writing <'a> everywhere would be exhausting, so the compiler applies three rules first, and only asks you to annotate when they don’t produce a complete answer:

  1. Each elided (unwritten) reference in the input gets its own distinct lifetime.
  2. If there’s exactly one input lifetime, it’s assigned to every elided output lifetime.
  3. If one of the inputs is &self or &mut self (i.e. this is a method), self’s lifetime is assigned to every elided output lifetime.

If none of these rules pin down the output, the compiler stops and asks you to write the lifetime yourself — which is exactly what happened with longest above (two input lifetimes, no &self, so rule 2 and rule 3 both fail to apply).

fn first_word(s: &str) -> &str {
    // desugars to: fn first_word<'a>(s: &'a str) -> &'a str   (rule 2)
    s.split_whitespace().next().unwrap_or("")
}

struct Wrapper {
    text: String,
}

impl Wrapper {
    fn text(&self) -> &str {
        // desugars to: fn text<'a>(&'a self) -> &'a str        (rule 3)
        &self.text
    }
}

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

    let w = Wrapper { text: String::from("hi there") };
    println!("{}", w.text());
}

What the compiler is thinking: elision isn’t magic — it’s a fixed, mechanical desugaring that runs before borrow checking. fn first_word(s: &str) -> &str and fn first_word<'a>(s: &'a str) -> &'a str are the exact same function as far as the compiler is concerned; the first form just lets you skip typing something rule 2 would have inferred anyway.

A struct holding a borrowed reference

A struct that stores a reference has to say how long that reference — and therefore every instance of the struct — is allowed to live:

struct Excerpt<'a> {
    text: &'a str,
}

impl<'a> Excerpt<'a> {
    fn first_sentence(&self) -> &str {
        // elided via rule 3, and self's own lifetime is tied to 'a
        self.text.split('.').next().unwrap_or("")
    }
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let excerpt = Excerpt { text: &novel[..] };
    println!("{}", excerpt.first_sentence());
    // `excerpt` can't outlive `novel` — the compiler enforces that at compile time.
}

Excerpt<'a> says “an Excerpt can’t outlive the string slice it borrows from.” The compiler now rejects any code that would let excerpt be used after novel is dropped — the same guarantee a single borrowed reference gets, extended to a whole struct.

'static: string literals live for the whole program

fn greeting() -> &'static str {
    "hello, world" // string literals are baked into the compiled binary
}

fn main() {
    let s: &'static str = "I live forever";
    println!("{}", greeting());
    println!("{s}");
}

String literals have type &'static str because the text is embedded directly in the binary at compile time — it’s never freed, so it’s valid for the entire run of the program. This is the “obvious” meaning of 'static, and it’s the one people usually learn first.

'static in a trait bound: not “is global”

The confusing case is a bound like T: 'static on a generic function. It does not mean the value is a global constant that lives forever — it means “T contains no references that could expire early.” A type satisfies T: 'static either by owning all its data outright, or by only borrowing data that is itself 'static.

fn print_it<T: std::fmt::Debug + 'static>(value: T) {
    println!("{value:?}");
}

fn main() {
    let owned = String::from("owned data"); // a normal, short-lived local variable
    print_it(owned); // OK: `String` owns its bytes, so it satisfies 'static trivially

    let n = 5;
    print_it(n); // OK: i32 owns its data too
}

owned is an ordinary local String that will be dropped like any other value — nothing about it is “global.” It satisfies T: 'static simply because it doesn’t borrow anything with a shorter lifetime. Contrast that with an actual borrow:

fn print_it<T: std::fmt::Debug + 'static>(value: T) {
    println!("{value:?}");
}

fn main() {
    let text = String::from("short-lived");
    let borrowed: &str = &text;
    // print_it(borrowed); // ERROR: `borrowed` doesn't satisfy 'static
    println!("{borrowed}");
}

Uncommenting print_it(borrowed) fails to compile: &text only lives as long as text does, which is nowhere near 'static, so &str here can’t satisfy the bound. Swap it for an owned String (or a genuine &'static str, like a string literal) and it compiles again.

Common mistakes

  • Reading 'static as “this is a global.” It means “no borrow inside this value expires before the program theoretically could end” — an owned String or i32 satisfies it trivially, even as a completely ordinary, short-lived local variable.
  • Reaching for 'static to make a lifetime error disappear. It usually just relocates the bug — now you can’t pass in a borrowed value at all where a 'static bound is required. Prefer fixing the ownership (clone the data, or store an owned type) over forcing a 'static bound.
  • Expecting elision to work with two-or-more distinct input references and no &self. The compiler won’t guess which input the output borrows from; that’s exactly when you must annotate explicitly, like longest<'a> above.
  • Forgetting a struct that holds a reference needs a lifetime parameter at all. struct Excerpt { text: &str } alone doesn’t compile — the compiler demands a named lifetime so it knows how long an Excerpt is allowed to live.

More examples

Two elided input lifetimes with no relation

A logging helper takes a tag and a message as separate references but never ties them together in its return type — the compiler gives each its own lifetime and never needs them to match.

fn longer_len(a: &str, b: &str) -> usize {
    // desugars to fn longer_len<'a, 'b>(a: &'a str, b: &'b str) -> usize
    // 'a and 'b never need to relate, because nothing borrows from either in the return
    a.len().max(b.len())
}

fn main() {
    let tag = "INFO";
    let message = "server started";
    println!("{}", longer_len(tag, message));
}

A 'static string constant

An app’s version string is baked into the binary and needs to be readable from anywhere, for the entire run of the program.

static APP_VERSION: &str = "2.4.0";

fn main() {
    println!("running version {APP_VERSION}");
}

A generic function with a T: 'static bound

A simple type-erased cache needs to guarantee that whatever you hand it doesn’t contain a short-lived borrow, so it can hold onto the value safely.

use std::any::Any;

fn store<T: 'static>(value: T) -> Box<dyn Any> {
    Box::new(value)
}

fn main() {
    let boxed = store(String::from("cached result"));
    if let Some(text) = boxed.downcast_ref::<String>() {
        println!("{text}");
    }
}

A struct with a lifetime parameter used across two methods

A support ticket wraps a borrowed code string and offers two different views on it — a getter and a VIP check — both tied to the same lifetime.

struct Ticket<'a> {
    code: &'a str,
}

impl<'a> Ticket<'a> {
    fn code(&self) -> &str {
        self.code
    }

    fn is_vip(&self) -> bool {
        self.code.starts_with("VIP")
    }
}

fn main() {
    let raw = String::from("VIP-1234");
    let ticket = Ticket { code: &raw };
    println!("{} vip={}", ticket.code(), ticket.is_vip());
}

Where 'static shows up for real: spawning a thread

std::thread::spawn requires everything the closure captures to be 'static, because the new thread might outlive the function that spawned it — an owned value satisfies that automatically.

use std::thread;

fn main() {
    let report = String::from("nightly build passed");
    let handle = thread::spawn(move || {
        println!("{report}");
    });
    handle.join().unwrap();
}

Your turn

This struct is supposed to hold a borrowed word, but it’s missing something.

struct Highlight {
    word: &str, // missing lifetime specifier
}

fn main() {
    let text = String::from("Rust is fun");
    let h = Highlight { word: &text[0..4] };
    println!("{}", h.word);
}
Show solution

A struct can’t hold a reference without declaring how long that reference — and the struct itself — is allowed to live. Add a lifetime parameter and use it on the field:

struct Highlight<'a> {
    word: &'a str, // tied to whatever it borrows from
}

fn main() {
    let text = String::from("Rust is fun");
    let h = Highlight { word: &text[0..4] };
    println!("{}", h.word); // "Rust"
}

Highlight<'a> now says “a Highlight can’t outlive the string slice it points to,” so the compiler can check that h never gets used after text would have gone out of scope.

Quick check

Remember this

  • Elision rule 1: every elided input reference gets its own lifetime.
  • Elision rule 2: with exactly one input lifetime, it’s assigned to all elided outputs.
  • Elision rule 3: with &self/&mut self, self’s lifetime is assigned to all elided outputs.
  • When none of the rules apply (e.g. two input references, no &self), you must annotate the lifetime yourself.
  • A struct holding a reference needs a lifetime parameter tying the struct’s validity to the data it borrows.
  • 'static means “can live for the whole program” — string literals are 'static; a T: 'static bound just means T owns its data or only borrows 'static data, not that the value itself is global.

Go deeper

Next:

Traits

Intermediate · Abstractions

What & why

A trait is a list of things a type promises it can do. It lets you write one function that works with any type keeping that promise, instead of writing the same function over and over for each concrete type. If you’ve ever wished you could say “I don’t care what this thing actually is, as long as it can be printed / compared / summarized,” traits are how you say exactly that.

The idea, slowly

Think of a trait as a job description, not a family tree

“Barista” is a job description: anyone who can take an order, make a coffee, and hand it over can do the job. The job description doesn’t care whether you’re tall, short, or left-handed — it only lists the behavior required. A trait works the same way: it lists behavior (methods), and any type that provides that behavior “qualifies,” no matter how unrelated the types are otherwise.

Defining a trait, and implementing it for a type

A trait is a name plus a list of method signatures — the method’s shape, without a body:

trait Summary {
    fn summarize(&self) -> String;
}

struct Article {
    headline: String,
}

struct Tweet {
    handle: String,
    text: String,
}

impl Summary for Article {
    fn summarize(&self) -> String {
        format!("ARTICLE: {}", self.headline)
    }
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("@{}: {}", self.handle, self.text)
    }
}

fn main() {
    let a = Article { headline: String::from("Rust 2.0 announced") };
    let t = Tweet { handle: String::from("rustlang"), text: String::from("ship it") };

    println!("{}", a.summarize());
    println!("{}", t.summarize());
}

Read it slowly:

  • trait Summary { fn summarize(&self) -> String; } says “anything that is Summary must have a summarize method that returns a String.” The ; after the signature means there’s no body — the trait only describes the promise, not how to keep it.
  • impl Summary for Article { ... } is where Article actually keeps that promise. Tweet keeps the same promise in a completely different way. Neither type knows the other exists.
  • &self borrows the value the method is called on — same & you’ve seen everywhere else.

What the compiler is thinking: when it sees a.summarize(), it looks for an impl Summary for Article block. Found it, method matches, done. Delete that impl block and the error becomes no method named 'summarize' found for struct 'Article' — the compiler is telling you the promise was never kept for that type.

Default method bodies: behavior for free

A trait method can come with a body. Any type that implements the trait gets that body automatically unless it chooses to override it:

trait Summary {
    fn summarize(&self) -> String;

    // default method — built entirely out of summarize()
    fn headline(&self) -> String {
        format!("[SUMMARY] {}", self.summarize())
    }
}

struct Article {
    title: String,
}

impl Summary for Article {
    fn summarize(&self) -> String {
        self.title.clone()
    }
    // headline() is not written here — we get the default for free
}

fn main() {
    let a = Article { title: String::from("Ferris learns to fly") };
    println!("{}", a.headline()); // [SUMMARY] Ferris learns to fly
}

Article only implemented summarize, but headline came along for free because the trait already knew how to build it out of summarize. This is how a lot of the standard library works: implement one small required method, and a whole family of related methods unlock automatically (Iterator is the biggest example of this pattern — more on that later in the course).

A type can also override a default if it has a better way to do it — just write the method with a body in the impl block, same as any required method.

Trait bounds: writing a function against the promise, not the type

The payoff of a trait is writing one function that works for anything implementing it. This is called a trait bound, and there are two equivalent-looking spellings:

trait Summary {
    fn summarize(&self) -> String;
}

struct Article {
    title: String,
}

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

// spelling 1: impl Trait in argument position (sugar)
fn announce_a(item: &impl Summary) {
    println!("Breaking: {}", item.summarize());
}

// spelling 2: a generic type parameter with a trait bound
fn announce_b<T: Summary>(item: &T) {
    println!("Breaking: {}", item.summarize());
}

fn main() {
    let a = Article { title: String::from("Rust ships const generics") };
    announce_a(&a);
    announce_b(&a);
}

fn announce_a(item: &impl Summary) reads as “a reference to some type that implements Summary — I don’t care which one.” fn announce_b<T: Summary>(item: &T) says the same thing more explicitly: “there’s a type T, and I require T: Summary.” Both compile to the same thing. The generic spelling (<T: Summary>) is the one you need once a type has to show up more than once with the guarantee it’s the same type both times — impl Trait sugar can’t express that:

trait Summary {
    fn summarize(&self) -> String;
}

// impl Trait can't say "same T twice" — this needs the generic form
fn longer_summary<T: Summary>(a: &T, b: &T) -> String {
    // both a and b are guaranteed to be the same concrete type
    let sa = a.summarize();
    let sb = b.summarize();
    if sa.len() >= sb.len() { sa } else { sb }
}

struct Note(String);
impl Summary for Note {
    fn summarize(&self) -> String { self.0.clone() }
}

fn main() {
    let n1 = Note(String::from("short"));
    let n2 = Note(String::from("a much longer note here"));
    println!("{}", longer_summary(&n1, &n2));
}

where clauses: the same bounds, easier to read

Once a function needs several type parameters with several bounds each, cramming everything into the <...> list gets hard to read:

use std::fmt::Debug;

trait Summary {
    fn summarize(&self) -> String;
}

// hard to read: bounds crowd the signature
fn report_a<T: Summary + Clone, U: Debug + Clone>(item: &T, meta: &U) -> String {
    format!("{} ({:?})", item.summarize(), meta)
}

// same bounds, moved into a where clause — the signature stays scannable
fn report_b<T, U>(item: &T, meta: &U) -> String
where
    T: Summary + Clone,
    U: Debug + Clone,
{
    format!("{} ({:?})", item.summarize(), meta)
}

struct Note(String);
impl Summary for Note {
    fn summarize(&self) -> String { self.0.clone() }
}
impl Clone for Note {
    fn clone(&self) -> Self { Note(self.0.clone()) }
}

fn main() {
    let n = Note(String::from("meeting at 5"));
    println!("{}", report_a(&n, &"tag-1"));
    println!("{}", report_b(&n, &"tag-2"));
}

report_a and report_b are identical to the compiler — where is purely a readability tool. Reach for it once you have more than one bound, or bounds that combine multiple traits with +.

Not inheritance

If you come from Java or Python, resist “trait = base class.” A trait is not a parent that a type is a kind of. It’s a capability a type has. One type can implement many unrelated traits (Summary and Clone and Debug), and none of them is its “parent.” Think “can do,” not “is a.”

Common mistakes

  • Forgetting the impl block. Writing trait Summary { ... } alone does nothing for Article. You must write impl Summary for Article. The error is the trait bound 'Article: Summary' is not satisfied or no method named 'summarize' found — both mean “you never told me how this type keeps the promise.”
  • Calling a trait method with no bound on the generic type. fn f<T>(x: T) { x.summarize(); } fails with no method named 'summarize' found for type parameter 'T', because unbounded T could be literally anything — the compiler has zero information about it. The fix is always to add the bound: fn f<T: Summary>(x: T).
  • Mixing up ; and a body in a trait definition. fn summarize(&self) -> String; (with ;) is a required method. fn summarize(&self) -> String { ... } (with a body) is a default method. Swapping these changes what implementers must write.
  • Calling a trait method without the trait in scope. If a trait lives in another module or crate, you must use it before its methods are callable on a value, even if the type already implements it. The error is method not found, fixed with use path::to::Trait;.
  • Thinking traits carry data. Traits describe behavior, not fields. If you want shared state, that’s what a struct is for — a trait can require a method that exposes the state, but it can’t hold the state itself.

More examples

Billing plans with different pricing

A subscription app needs to compute “how much does this cost per month” the same way for a free-tier plan and a per-seat plan — each with completely different math behind that one shared method.

trait Billable {
    fn monthly_cost(&self) -> f64;
}

struct Basic;
struct Pro { seats: u32 }

impl Billable for Basic {
    fn monthly_cost(&self) -> f64 { 9.99 }
}
impl Billable for Pro {
    fn monthly_cost(&self) -> f64 { 19.99 * self.seats as f64 }
}

fn main() {
    let basic = Basic;
    let pro = Pro { seats: 3 };
    println!("basic: ${:.2}", basic.monthly_cost());
    println!("pro: ${:.2}", pro.monthly_cost());
}

A default you can override

Most log lines are just “info,” but errors need different formatting. A default method covers the common case for free; one type opts out and writes its own.

trait Logger {
    fn log(&self, msg: &str) -> String {
        format!("[INFO] {}", msg)
    }
}

struct Console;
struct ErrorLogger;

impl Logger for Console {} // uses the default as-is

impl Logger for ErrorLogger {
    fn log(&self, msg: &str) -> String {
        format!("[ERROR] {}", msg)
    }
}

fn main() {
    println!("{}", Console.log("server started"));
    println!("{}", ErrorLogger.log("disk full"));
}

Requiring two abilities at once

Sometimes a function needs a type it can both print for debugging and duplicate — that’s two separate promises stacked into one bound.

use std::fmt::Debug;

fn snapshot<T: Debug + Clone>(item: T) -> (T, T) {
    println!("snapshotting: {:?}", item);
    (item.clone(), item)
}

fn main() {
    let (a, b) = snapshot(vec![1, 2, 3]);
    println!("{:?} {:?}", a, b);
}

A method that leans on another method

full_report doesn’t know how to compute a name or a salary itself — it just calls the other required methods on self and stitches the results together.

trait Employee {
    fn name(&self) -> String;
    fn salary(&self) -> f64;

    fn full_report(&self) -> String {
        format!("{} earns ${:.2}/mo", self.name(), self.salary())
    }
}

struct Contractor { name: String, rate: f64 }

impl Employee for Contractor {
    fn name(&self) -> String { self.name.clone() }
    fn salary(&self) -> f64 { self.rate * 160.0 }
}

fn main() {
    let c = Contractor { name: String::from("Dana"), rate: 45.0 };
    println!("{}", c.full_report());
}

One function, many unrelated “can-do” types

A shape and an invoice have nothing in common as types — but both can satisfy the same trait bound as long as they each keep the promise in their own way.

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

struct Circle { r: f64 }
struct Invoice { id: u32 }

impl Describe for Circle {
    fn describe(&self) -> String { format!("circle r={}", self.r) }
}
impl Describe for Invoice {
    fn describe(&self) -> String { format!("invoice #{}", self.id) }
}

fn log_it(item: &impl Describe) {
    println!("LOG: {}", item.describe());
}

fn main() {
    log_it(&Circle { r: 2.0 });
    log_it(&Invoice { id: 4021 });
}

Your turn

This program wants announce to work for any type with a summarize method, but it doesn’t compile.

trait Summary {
    fn summarize(&self) -> String;
}

struct Article {
    title: String,
}

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

fn announce<T>(item: &T) {
    println!("Breaking: {}", item.summarize());
}

fn main() {
    let a = Article { title: String::from("Rust turns 10") };
    announce(&a);
}
Show solution

T in fn announce<T> has no bound, so the compiler treats it as “could be absolutely anything” — it has no idea summarize exists. Add the trait bound:

trait Summary {
    fn summarize(&self) -> String;
}

struct Article {
    title: String,
}

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

fn announce<T: Summary>(item: &T) {
    println!("Breaking: {}", item.summarize());
}

fn main() {
    let a = Article { title: String::from("Rust turns 10") };
    announce(&a);
}

T: Summary tells the compiler “whatever concrete type fills in T, it will have summarize” — that’s the whole bound, and it’s what makes item.summarize() legal inside the generic function.

Quick check

Remember this

  • A trait is a list of behavior (method signatures) a type promises to provide.
  • impl TraitName for TypeName { ... } is where a type actually keeps the promise.
  • fn f<T: Trait>(x: T) and fn f(x: &impl Trait) both mean “accept anything implementing Trait”; use the <T: Trait> form when the same type has to appear more than once.
  • Default method bodies give implementers free behavior built on top of the required methods.
  • where clauses hold the exact same bounds as <T: ...> — use them once a signature has more than one bound to keep it readable.
  • Traits are “can do,” not “is a” — capabilities, not inheritance.

Go deeper

Next:

Trait objects and dyn

Intermediate · Abstractions

What & why

Once you can write fn f<T: Trait>(x: T), a new problem shows up: what if you need a single collection that holds many different concrete types, as long as they all implement the same trait — a Vec of shapes where some are circles and some are squares? Generics can’t do that; each generic function is compiled separately per type, so there’s no single T that means “circle or square.” dyn Trait is Rust’s answer: a way to talk about “some type implementing Traitat runtime, paid for with a small, explicit cost.

The idea, slowly

Static dispatch: generics become copies, not runtime checks

When you write a generic function and call it with a concrete type, the compiler doesn’t emit one flexible function — it stamps out a separate copy for every type you call it with. This is called monomorphization:

trait Shape {
    fn area(&self) -> f64;
}

struct Circle {
    r: f64,
}
struct Square {
    side: f64,
}

impl Shape for Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.r * self.r
    }
}
impl Shape for Square {
    fn area(&self) -> f64 {
        self.side * self.side
    }
}

fn print_area<T: Shape>(s: &T) {
    println!("{:.2}", s.area());
}

fn main() {
    let c = Circle { r: 2.0 };
    let sq = Square { side: 3.0 };
    print_area(&c);  // compiler generates print_area::<Circle>
    print_area(&sq); // compiler generates a SEPARATE print_area::<Square>
}

Behind the scenes, this compiles as if you’d written print_area_circle and print_area_square by hand — two separate functions, each knowing its exact type at compile time. There’s no lookup at runtime; the call to s.area() jumps straight to Circle::area or Square::area because the compiler already knows which one. This is static dispatch: fast, and free of any runtime cost — but it means T is always one fixed type per call site. You cannot put a Circle and a Square in the same Vec<T>, because Vec<T> requires every element to be the same T.

The problem: mixed types, one interface

trait Shape {
    fn area(&self) -> f64;
}

struct Circle { r: f64 }
struct Square { side: f64 }
impl Shape for Circle {
    fn area(&self) -> f64 { std::f64::consts::PI * self.r * self.r }
}
impl Shape for Square {
    fn area(&self) -> f64 { self.side * self.side }
}

fn main() {
    // This does NOT compile: Circle and Square are different types,
    // and Vec<T> needs one T for every element.
    // let shapes = vec![Circle { r: 2.0 }, Square { side: 3.0 }];

    // dyn Trait is the escape hatch: "I don't know or care which
    // concrete Shape this is, only that it has .area()."
    let shapes: Vec<Box<dyn Shape>> = vec![
        Box::new(Circle { r: 2.0 }),
        Box::new(Square { side: 3.0 }),
    ];

    for s in &shapes {
        println!("{:.2}", s.area());
    }
}

Vec<Box<dyn Shape>> reads as “a growable list of boxed something-that-implements-Shape, and every element can be a different concrete type.” That’s exactly the flexibility monomorphized generics can’t offer.

Why dyn Trait needs a pointer

dyn Shape on its own is not a normal, usable type — it’s unsized (Rust calls this “dynamically sized”). A Circle is some fixed number of bytes; a Square is a different fixed number of bytes. dyn Shape means “whichever one of these it turns out to be,” and the compiler can’t reserve stack space for a value whose size it doesn’t know at compile time. That’s why dyn Shape by itself doesn’t compile as a variable or a Vec element — it has to sit behind a pointer (Box<dyn Shape>, &dyn Shape, or Rc<dyn Shape>), because a pointer is always the same fixed size (one machine word) no matter what it points to.

The pointer isn’t just an address, either — it’s a fat pointer: the address of the actual data, plus the address of a vtable (a small table of function pointers — one per trait method — generated once per concrete type). Calling s.area() through a dyn Shape looks up area in the vtable and jumps through it. That lookup is the “small runtime cost” of dynamic dispatch — one extra indirection compared to the direct call generics get for free.

What the compiler is thinking: with Box<dyn Shape>, it doesn’t need to know which Shape at compile time — it only needs to know the Box is one pointer wide and that the vtable it points to has an area slot. That’s enough to generate code that works for every current and future Shape implementer, without a single monomorphized copy per type.

Object safety: not every trait can become dyn

A trait can only become a trait object (dyn Trait) if the compiler can build that vtable — every method needs a fixed, known-in-advance shape. This property is traditionally called object safety (the compiler itself calls it dyn compatibility, the more current name for the same idea). Two common things break it:

#![allow(unused)]
fn main() {
trait Container {
    // generic method — breaks object safety.
    // A vtable would need one slot per possible T, which is unbounded.
    fn wrap<T>(&self, value: T) -> Vec<T> {
        vec![value]
    }
}
}

A method that takes a generic type parameter (fn wrap<T>(...)) can’t go in a vtable, because the vtable would need a different function pointer for every possible T — there’s no fixed list. Same problem with a method that returns Self by value: the caller side wouldn’t know how much space to reserve, since Self could be any concrete implementer.

If you try to write Box<dyn Container> for a trait like the one above, the compiler refuses with something like the trait 'Container' is not dyn compatible ... because method 'wrap' has generic type parameters. The fix is usually one of: keep the generic method but mark it where Self: Sized (which excludes it from the trait-object interface while keeping it for static-dispatch callers), split the trait into a dyn-compatible part and a generic-only part, or just accept that trait and use generics/impl Trait instead of dyn.

A third option: impl Trait in return position

Sometimes you don’t need a collection of mixed types — you just want to return “some type that implements Iterator” without writing out its long, awkward real name. impl Trait in return position does that, and it’s resolved entirely at compile time (no vtable, no Box required):

fn make_adder(n: i32) -> impl Fn(i32) -> i32 {
    move |x| x + n
}

fn main() {
    let add5 = make_adder(5);
    println!("{}", add5(10)); // 15
}

-> impl Fn(i32) -> i32 means “this returns some concrete type that implements Fn(i32) -> i32 — I’m not going to name it (closures don’t even have nameable types), but the caller can use it as if it does.” Unlike dyn Trait, every call to make_adder must return the same concrete type — you can’t have one branch return one closure type and another branch return a different one. If you need that, you’re back to Box<dyn Fn(i32) -> i32>.

Static dispatch (T: Trait / generics)impl Trait returndyn Trait
Dispatch costnone — direct callnone — direct callone vtable lookup
Can mix concrete types?no — one T per call siteno — one hidden type per functionyes, behind Box/&/Rc
Needs a pointer?nonoyes (unsized)
Binary sizebigger (copy per type)normalnormal

Common mistakes

  • Writing dyn Trait as a bare value or return type. fn make() -> dyn Shape { ... } fails with doesn't have a size known at compile-time. It needs a pointer: Box<dyn Shape> (owned, heap-allocated) or &dyn Shape (borrowed).
  • Trying to put a trait with a generic method behind dyn. The error the trait '...' is not dyn compatible means the trait isn’t object-safe — usually a generic method or a method returning Self.
  • Reaching for dyn Trait by default. If you always know the concrete type at each call site and never need to mix types in one collection, plain generics (T: Trait) are faster and give better compiler error messages — save dyn for when you genuinely need runtime-chosen, mixed types.
  • Forgetting impl Trait return position must be one concrete type per function. if cond { return ClosureA } else { return ClosureB } from an -> impl Fn(...) function fails to compile, because the two branches are different underlying closure types even though both implement Fn. Use Box<dyn Fn(...)> if the concrete type genuinely varies.
  • Confusing Box<dyn Trait> with Box<T>. Box<T> still knows exactly which T it holds and dispatches statically — only writing dyn in the type turns on dynamic dispatch.

More examples

Total area across mixed shapes

A floor-plan tool needs one number — total square footage — from a list of rooms that are circles, rectangles, and triangles all mixed together. dyn Shape is what lets one loop handle all three.

trait Shape {
    fn area(&self) -> f64;
}
struct Circle { r: f64 }
struct Rectangle { w: f64, h: f64 }
struct Triangle { base: f64, height: f64 }
impl Shape for Circle { fn area(&self) -> f64 { std::f64::consts::PI * self.r * self.r } }
impl Shape for Rectangle { fn area(&self) -> f64 { self.w * self.h } }
impl Shape for Triangle { fn area(&self) -> f64 { 0.5 * self.base * self.height } }

fn main() {
    let shapes: Vec<Box<dyn Shape>> = vec![
        Box::new(Circle { r: 1.0 }),
        Box::new(Rectangle { w: 2.0, h: 3.0 }),
        Box::new(Triangle { base: 4.0, height: 2.0 }),
    ];
    let total: f64 = shapes.iter().map(|s| s.area()).sum();
    println!("total area: {:.2}", total);
}

&dyn Trait vs impl Trait as a parameter

Both spellings accept “anything that implements Named,” but they read differently and fit different callers — impl Trait is the everyday default, &dyn Trait is what you reach for when the caller already has a trait object on hand.

trait Named {
    fn name(&self) -> &str;
}
struct Robot { label: String }
impl Named for Robot { fn name(&self) -> &str { &self.label } }

// impl Trait: compiler picks one concrete type per call site
fn greet_impl(n: &impl Named) {
    println!("hello, {} (impl Trait)", n.name());
}
// &dyn Trait: same call works through a vtable, useful when the caller
// only has a trait object handy (e.g. from a Vec<Box<dyn Named>>)
fn greet_dyn(n: &dyn Named) {
    println!("hello, {} (dyn Trait)", n.name());
}

fn main() {
    let r = Robot { label: String::from("R2") };
    greet_impl(&r);
    greet_dyn(&r);
}

A plugin-style callback list

A build tool wants to run an arbitrary list of registered steps in order, without knowing ahead of time how many there are or what each one does — Vec<Box<dyn Fn()>> is a list of “callable things,” not a list of one specific closure type.

fn main() {
    let handlers: Vec<Box<dyn Fn()>> = vec![
        Box::new(|| println!("handler 1: sending email")),
        Box::new(|| println!("handler 2: logging event")),
        Box::new(|| println!("handler 3: updating cache")),
    ];

    for handle in &handlers {
        handle();
    }
}

Storing a trait object in a struct field

An app that might notify users by email today and by SMS tomorrow doesn’t want its App struct locked to one concrete notifier type — it just stores “something that can notify.”

trait Notifier {
    fn notify(&self, msg: &str);
}
struct EmailNotifier;
impl Notifier for EmailNotifier {
    fn notify(&self, msg: &str) { println!("EMAIL: {}", msg); }
}

struct App {
    notifier: Box<dyn Notifier>,
}

fn main() {
    let app = App { notifier: Box::new(EmailNotifier) };
    app.notifier.notify("build finished");
}

Trait objects without Box: borrowed, not owned

When the concrete values already live on the stack and you just need a temporary mixed-type list, a borrowed &dyn Trait skips the heap allocation Box would need.

trait Animal {
    fn speak(&self) -> String;
}
struct Dog;
struct Cat;
impl Animal for Dog { fn speak(&self) -> String { String::from("Woof") } }
impl Animal for Cat { fn speak(&self) -> String { String::from("Meow") } }

fn main() {
    let dog = Dog;
    let cat = Cat;
    // no heap allocation needed — just borrow each one as a trait object
    let animals: Vec<&dyn Animal> = vec![&dog, &cat];

    for a in &animals {
        println!("{}", a.speak());
    }
}

Your turn

This program wants a Vec that holds both Circle and Square shapes behind one interface, but it doesn’t compile.

trait Shape {
    fn area(&self) -> f64;
}

struct Circle {
    r: f64,
}
struct Square {
    side: f64,
}

impl Shape for Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.r * self.r
    }
}
impl Shape for Square {
    fn area(&self) -> f64 {
        self.side * self.side
    }
}

fn main() {
    let shapes: Vec<dyn Shape> = vec![Circle { r: 2.0 }, Square { side: 3.0 }];

    for s in &shapes {
        println!("{:.2}", s.area());
    }
}
Show solution

Vec<dyn Shape> tries to store dyn Shape directly as the element type, but dyn Shape is unsized — the compiler doesn’t know how many bytes a “some kind of Shape” takes up, since Circle and Square are different sizes. Every trait-object element has to sit behind a pointer:

trait Shape {
    fn area(&self) -> f64;
}

struct Circle {
    r: f64,
}
struct Square {
    side: f64,
}

impl Shape for Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.r * self.r
    }
}
impl Shape for Square {
    fn area(&self) -> f64 {
        self.side * self.side
    }
}

fn main() {
    let shapes: Vec<Box<dyn Shape>> = vec![
        Box::new(Circle { r: 2.0 }),
        Box::new(Square { side: 3.0 }),
    ];

    for s in &shapes {
        println!("{:.2}", s.area());
    }
}

Box<dyn Shape> is a fixed-size fat pointer (data address + vtable address) no matter which Shape it points to, so Vec is happy to store many of them side by side even though the things they point to are different sizes.

Quick check

Remember this

  • Generics (T: Trait) use static dispatch: the compiler monomorphizes a separate copy per concrete type, so calls are direct and free — but every call site has exactly one T.
  • dyn Trait uses dynamic dispatch: one shared function per method, looked up through a vtable at runtime — a small cost that buys the ability to mix concrete types.
  • dyn Trait is unsized and must live behind a pointer: Box<dyn Trait> (owned), &dyn Trait (borrowed), or Rc<dyn Trait> (shared).
  • A trait is object-safe only if the compiler can build a fixed vtable for it — generic methods and methods returning Self break that.
  • impl Trait in return position is a third option: “some fixed concrete type, resolved at compile time” — no vtable, no pointer required, but every return path must produce the same underlying type.

Go deeper

Next:

Generics

Intermediate · Abstractions

What & why

Generics let you write a function or type once and use it with many different types, without copy-pasting a version for each. The magic word is a stand-in name (usually T) that means “some type — I’ll tell you which one when I use it.” It’s how Vec<T> can hold numbers or strings or your own structs from a single definition.

The idea, slowly

Imagine writing a “return the first item” function. Without generics you’d write one for Vec<i32>, another for Vec<String>, another for Vec<bool>… all identical except the type. That’s silly. Generics let you write it once with a placeholder.

A placeholder for a type

T is just a name — a variable, but for types instead of values:

fn first<T>(items: &[T]) -> &T {
    &items[0]
}

fn main() {
    let nums = [10, 20, 30];
    let words = ["red", "green", "blue"];

    println!("{}", first(&nums));  // works with i32
    println!("{}", first(&words)); // works with &str
}

Read fn first<T>(items: &[T]) -> &T:

  • <T> right after the name means “I’m introducing a type placeholder called T.” You declare it here, like declaring a variable, before you use it.
  • items: &[T] means “a slice of some type T.” (A slice &[T] is a borrowed view of a list — you saw slices earlier.)
  • -> &T means “I return a reference to that same type T.”

What the compiler is thinking: when you call first(&nums), the compiler notices nums is [i32; 3], so it decides “T is i32 this time” and stamps out a version of first specialized to i32. When you call first(&words), it stamps out another for &str. You wrote one function; the compiler quietly generated the concrete ones. This is why generics are called zero-cost: at runtime there’s no guessing, just the specific machine code, as fast as if you’d hand-written each version.

Generics need bounds to do anything

Here’s the catch that trips everyone up. Inside a generic function, T could be anything, so the compiler only lets you do things that work for every possible type. You can’t add two Ts, or print a T, or compare them — unless you promise that T can do those things. You make that promise with a trait bound.

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

fn main() {
    let nums = [3, 7, 2, 9, 4];
    let chars = ['a', 'z', 'm'];
    println!("{}", largest(&nums));  // 9
    println!("{}", largest(&chars)); // z
}

<T: PartialOrd + Copy> reads as “T is some type that supports PartialOrd (can be compared with >) and Copy (can be duplicated cheaply).” Those are the two abilities the function actually uses: item > biggest needs comparison, and let mut biggest = items[0] needs a copy. The + means “and also.”

Try it: delete PartialOrd from the bound and run. The compiler says binary operation > cannot be applied to type T — because you removed the promise that T can be compared. The bound isn’t red tape; it’s you telling the compiler exactly what T is allowed to do.

Generic structs

Types can be generic too. That’s exactly how the standard library defines things like Option<T> and Vec<T>:

struct Pair<T> {
    first: T,
    second: T,
}

fn main() {
    let ints = Pair { first: 1, second: 2 };
    let words = Pair { first: "hi", second: "bye" };

    println!("{} {}", ints.first, ints.second);
    println!("{} {}", words.first, words.second);
}

One Pair definition, usable with any type. Pair<i32> and Pair<&str> are both real types the compiler builds from your single template.

Common mistakes

  • Using an operation without the matching bound. Trying a > b or a + b or println!("{a}") on a bare T fails, because not every type supports it. The error names the missing trait, e.g. T doesn't implement std::fmt::Display. The fix is to add that trait to the bound: <T: Display>.
  • Forgetting to declare <T> before using it. fn first(items: &[T]) (no <T>) makes the compiler think T is a real type it should already know, giving cannot find type T in this scope. Declare it: fn first<T>(...).
  • Reaching for generics when one concrete type is fine. Generics earn their keep when several real types will flow through. If only i32 ever passes, a generic just adds noise. Add the placeholder when real variety shows up, not before.
  • Confusing generics with trait objects (dyn). <T: Greet> picks one concrete type per call and is resolved at compile time. &dyn Greet mixes different types at runtime. For most beginner code, generics are what you want.

More examples

Finding the smallest, not the largest

The same shape of function works whether you want the max or the min — only the comparison direction changes, and the bound needed is identical.

fn smallest<T: PartialOrd + Copy>(items: &[T]) -> T {
    let mut min = items[0];
    for &item in items {
        if item < min {
            min = item;
        }
    }
    min
}

fn main() {
    let prices = [19.99, 4.50, 12.25];
    let scores = [88, 92, 71, 95];
    println!("cheapest: {}", smallest(&prices));
    println!("lowest score: {}", smallest(&scores));
}

A generic struct holding one value

Not every generic type needs two fields like Pair<T> — sometimes you just want a single value wrapped with some extra behavior, usable with whatever type shows up.

struct Wrapper<T> {
    value: T,
}

impl<T> Wrapper<T> {
    fn get(&self) -> &T {
        &self.value
    }
}

fn main() {
    let w1 = Wrapper { value: 42 };
    let w2 = Wrapper { value: String::from("hello") };
    println!("{}", w1.get());
    println!("{}", w2.get());
}

Two independent type parameters

A label and a value rarely share a type — combine accepts any two types at all, as long as both can be displayed.

use std::fmt::Display;

fn combine<T: Display, U: Display>(label: T, value: U) -> String {
    format!("{}: {}", label, value)
}

fn main() {
    println!("{}", combine("age", 30));
    println!("{}", combine('x', 3.14));
}

Constraining a generic just enough to print it

Sometimes the only thing a function does with T is print it for debugging — so the only bound it needs is Debug, nothing more.

use std::fmt::Debug;

fn dump<T: Debug>(label: &str, item: T) {
    println!("{} = {:?}", label, item);
}

fn main() {
    dump("nums", vec![1, 2, 3]);
    dump("pair", (true, "yes"));
}

Clamping a value into a range

A generic isn’t just for comparing two values — clamp_value works on any type that can be ordered, whether that’s a game score, a volume level, or a price.

fn clamp_value<T: PartialOrd>(value: T, min: T, max: T) -> T {
    if value < min {
        min
    } else if value > max {
        max
    } else {
        value
    }
}

fn main() {
    println!("{}", clamp_value(15, 0, 10));   // 10
    println!("{}", clamp_value(-5, 0, 10));   // 0
    println!("{}", clamp_value(4.5, 0.0, 10.0)); // 4.5
}

Your turn

This function is supposed to return the bigger of two values, for any comparable type. It doesn’t compile. Fix the bound so it prints 9 and z.

fn max_of<T>(a: T, b: T) -> T {
    if a > b { a } else { b }
}

fn main() {
    println!("{}", max_of(4, 9));
    println!("{}", max_of('a', 'z'));
}
Show solution

The body compares with > and returns one of the values, so T must promise it can be compared (PartialOrd) and copied (Copy, since a and b are used by value):

fn max_of<T: PartialOrd + Copy>(a: T, b: T) -> T {
    if a > b { a } else { b }
}

fn main() {
    println!("{}", max_of(4, 9));
    println!("{}", max_of('a', 'z'));
}

Without PartialOrd the > isn’t allowed; the bound grants exactly the ability the code uses.

Quick check

Remember this

  • A generic type parameter like T is a placeholder for a type, chosen when you call the code.
  • Declare it in angle brackets first: fn name<T>(...) or struct Name<T>.
  • Inside a generic, you can only use abilities you promise via trait bounds: <T: PartialOrd + Copy>.
  • The + in a bound means “and also this trait.”
  • Generics are zero-cost: the compiler generates a specialized version per concrete type, so there’s no runtime penalty.

Go deeper

Next:

Vectors

Beginner · Abstractions

What & why

Arrays in Rust have a size fixed at compile time — [i32; 3] is always exactly 3 integers, forever. Almost nothing in real programs works that way: shopping carts grow, search results come back with an unknown count, logs accumulate one line at a time. Vec<T> is Rust’s growable list — a row of same-typed boxes you can keep adding to (or removing from) while the program runs. It’s the collection you reach for by default.

The idea, slowly

Building one: Vec::new() vs vec![...]

fn main() {
    let mut a: Vec<i32> = Vec::new(); // empty, type must be known somehow
    a.push(1);
    a.push(2);

    let b = vec![10, 20, 30]; // vec! macro — pre-filled, type inferred

    println!("{:?}", a);
    println!("{:?}", b);
}

Vec::new() starts empty and figures out its element type either from an annotation (Vec<i32>) or from the first thing you push into it. vec![...] is a macro (note the !) that builds a Vec already holding the values you list — reach for it whenever you know the starting contents up front. {:?} is the “debug” format specifier; it can print a whole collection at once, which plain {} cannot.

push and pop

fn main() {
    let mut stack = Vec::new();
    stack.push(1);
    stack.push(2);
    stack.push(3);
    println!("{:?}", stack); // [1, 2, 3]

    // pop returns Option<T> — Some(value) if there was one, None if empty
    match stack.pop() {
        Some(top) => println!("popped {}", top), // popped 3
        None => println!("nothing to pop"),
    }
    println!("{:?}", stack); // [1, 2]
}

push adds to the end; pop removes and returns the last element. Because an empty Vec has nothing to pop, pop() hands back an Option<T> instead of just TSome(value) if something was there, None if the vector was empty. This is the same “don’t crash, tell the caller” pattern you’ve already met with Option.

Indexing: v[i] panics, .get(i) doesn’t

fn main() {
    let nums = vec![10, 20, 30];

    println!("{}", nums[1]); // 20 — fine, index 1 exists

    match nums.get(10) {
        Some(n) => println!("found {}", n),
        None => println!("nothing at index 10"), // this runs
    }

    // nums[10]; // would PANIC: index out of bounds
}

v[i] reads by position, counting from 0, and panics (crashes the program on purpose) if i is out of range. .get(i) is the crash-free alternative — it returns Option<&T>, Some(&value) if the index exists and None if it doesn’t. Use v[i] when you already know the index is valid (e.g. you just checked v.len()); use .get(i) whenever the index comes from somewhere you don’t fully trust, like user input.

Iterating

fn main() {
    let nums = vec![1, 2, 3];

    for n in &nums {
        // n: &i32 — borrowing, nums still usable afterward
        print!("{} ", n);
    }
    println!();

    let mut mutable_nums = vec![1, 2, 3];
    for n in &mut mutable_nums {
        *n *= 10; // n: &mut i32 — modify in place
    }
    println!("{:?}", mutable_nums); // [10, 20, 30]

    for n in nums {
        // n: i32 — this CONSUMES nums; it can't be used after this loop
        print!("{} ", n);
    }
    println!();
}

for x in &v borrows and yields &T — the vector is untouched and usable afterward. for x in &mut v borrows mutably and yields &mut T — you can modify elements through it. for x in v (no &) takes ownership of the vector and hands you owned T values one at a time; after that loop, v is gone. Reach for &v by far the most often.

Sorting: sort and sort_by

fn main() {
    let mut nums = vec![5, 1, 4, 2, 3];
    nums.sort(); // ascending, uses the type's natural ordering
    println!("{:?}", nums); // [1, 2, 3, 4, 5]

    let mut words = vec!["pear", "fig", "kiwi"];
    words.sort_by(|a, b| a.len().cmp(&b.len())); // custom comparator: shortest first
    println!("{:?}", words); // ["fig", "kiwi", "pear"]

    let mut people = vec![("Bea", 41), ("Al", 30), ("Cy", 25)];
    people.sort_by_key(|p| p.1); // sort by age — often clearer than sort_by
    println!("{:?}", people); // [("Cy", 25), ("Al", 30), ("Bea", 41)]
}

sort() works for types with a natural order (numbers, strings, …). sort_by takes a closure that compares two elements and returns an Ordering — use it for custom rules. sort_by_key is the common special case “sort by this one field,” and reads more clearly than a full comparator when that’s all you need.

retain: keep only what passes a test

fn main() {
    let mut nums = vec![1, 2, 3, 4, 5, 6];
    nums.retain(|&n| n % 2 == 0); // keep only even numbers
    println!("{:?}", nums); // [2, 4, 6]
}

retain walks the vector and removes every element for which the closure returns false, in place — no separate “filter into a new vector” step needed when you just want to prune what’s already there.

dedup: only removes consecutive duplicates

fn main() {
    let mut nums = vec![1, 1, 2, 2, 2, 1, 3];
    nums.dedup();
    println!("{:?}", nums); // [1, 2, 1, 3] — NOT fully deduplicated!

    let mut nums2 = vec![1, 1, 2, 2, 2, 1, 3];
    nums2.sort();   // [1, 1, 1, 2, 2, 2, 3]
    nums2.dedup();  // now every duplicate is adjacent
    println!("{:?}", nums2); // [1, 2, 3]
}

dedup only collapses runs of adjacent equal elements — it does not scan the whole vector for duplicates anywhere. If you want every duplicate gone regardless of position, sort() first so equal elements become neighbors, then dedup().

Vec::with_capacity: avoid reallocating while you grow

fn main() {
    let mut a = Vec::new(); // capacity 0 — first few pushes reallocate
    for i in 0..5 {
        a.push(i);
    }

    let mut b: Vec<i32> = Vec::with_capacity(5); // room for 5 reserved up front
    for i in 0..5 {
        b.push(i); // none of these pushes need to reallocate
    }

    println!("{:?} {:?}", a, b);
}

A Vec is backed by one contiguous block of heap memory. When it runs out of room, push has to allocate a bigger block, copy every existing element over, and free the old block — an O(n) operation that happens occasionally as a vector grows (Rust’s standard library roughly doubles capacity each time, so this happens less and less often, but it still happens). If you know up front roughly how many elements you’ll end up with, Vec::with_capacity(n) reserves that space once, so the pushes that follow don’t trigger any reallocation at all. v.len() is how many elements are actually there; v.capacity() is how much room is reserved — they’re allowed to differ.

Common mistakes

  • Forgetting mut. push, pop, sort, retain, and dedup all mutate the vector, so it must be let mut. The error is cannot borrow as mutable.
  • Indexing out of bounds. v[99] on a short vector panics at runtime with index out of bounds. When the index isn’t already known to be valid, use v.get(99) and handle the Option instead.
  • Treating pop() as if it returns the value directly. It returns Option<T>, because there might be nothing left to pop. let x: i32 = v.pop(); is a type error — you need v.pop().unwrap() (if you’re sure), or a match/if let.
  • Expecting dedup() to remove all duplicates. It only merges adjacent equal runs. Duplicates scattered through an unsorted vector survive dedup() untouched — sort first.
  • Skipping Vec::with_capacity in a hot loop. Repeatedly pushing into a Vec::new() when you already know the final size causes avoidable reallocations and copies. Not wrong, just slower than it needs to be.
  • Trying to print a Vec with {}. Use {:?} (debug format) for whole vectors; plain {} only works for single values with a Display implementation.

More examples

Shopping cart total

Once prices are in a Vec, .iter().sum() turns “add up every item” into one line instead of a hand-rolled loop.

fn main() {
    let cart = vec![19.99, 5.50, 3.25, 12.00];
    let total: f64 = cart.iter().sum();
    println!("cart total: ${:.2}", total);
}

Deduplicating scraped URLs

A scraper that follows links will see the same page more than once. Sorting then dedup-ing turns a messy list of visited pages into the distinct set.

fn main() {
    let mut urls = vec![
        "site.com/a".to_string(),
        "site.com/b".to_string(),
        "site.com/a".to_string(),
        "site.com/c".to_string(),
        "site.com/b".to_string(),
    ];

    urls.sort();
    urls.dedup();

    println!("{} unique pages found", urls.len());
    println!("{:?}", urls);
}

An undo history as a stack

A text editor’s undo button always undoes the most recent action — exactly what push/pop on a Vec give you for free.

fn main() {
    let mut history: Vec<String> = Vec::new();
    history.push("typed 'hello'".to_string());
    history.push("bolded text".to_string());
    history.push("inserted image".to_string());

    if let Some(last_action) = history.pop() {
        println!("undoing: {}", last_action);
    }
    println!("remaining history: {:?}", history);
}

Chunking a list into batches

An email service that only accepts 2 recipients per request needs the full list broken into fixed-size groups first — .chunks(n) does exactly that without any manual index math.

fn main() {
    let emails = vec!["a@x.com", "b@x.com", "c@x.com", "d@x.com", "e@x.com"];

    for (i, batch) in emails.chunks(2).enumerate() {
        println!("sending batch {}: {:?}", i + 1, batch);
    }
}

Removing an item by value

Banning a user should remove every occurrence of their name from a list, not just one — retain keeps everything that doesn’t match.

fn main() {
    let mut usernames = vec!["alice", "spam_bot", "bob", "spam_bot", "carol"];
    usernames.retain(|&name| name != "spam_bot");
    println!("{:?}", usernames);
}

Your turn

This program should pop the top of a stack and print it, but it doesn’t compile.

fn main() {
    let mut stack = vec![1, 2, 3];
    let top: i32 = stack.pop();
    println!("top: {}", top);
}
Show solution

stack.pop() returns Option<i32>, not i32 directly — the vector might have been empty, so pop has to be able to say “nothing here.” The annotation let top: i32 = ... demands an i32, and the compiler catches the mismatch (expected i32, found Option<i32>) before the program ever runs.

fn main() {
    let mut stack = vec![1, 2, 3];

    match stack.pop() {
        Some(top) => println!("top: {}", top),
        None => println!("stack was empty"),
    }
}

Handling both cases with match (or .unwrap() if you’re certain the vector is non-empty) is what pop’s Option return type is asking you to do.

Quick check

Remember this

  • Vec::new() starts empty; vec![...] builds one pre-filled. Both need let mut to be modified.
  • v[i] panics on an out-of-range index; v.get(i) returns Option<&T> instead — no crash.
  • pop() returns Option<T>, not T, because the vector might be empty.
  • for x in &v borrows (vector stays usable); for x in v consumes it.
  • sort/sort_by/sort_by_key reorder in place; retain keeps only elements passing a test; dedup removes only adjacent duplicates, so sort first if you want them all gone.
  • Vec::with_capacity(n) reserves space up front and avoids the reallocate-and-copy cost of growing one push at a time.

Go deeper

Next:

HashMaps and HashSets

Intermediate · Abstractions

What & why

A Vec finds things by position — index 0, 1, 2. A lot of real data doesn’t have a natural position; it has a natural name. “How many times did "the" appear?” “What’s the user with id 42?” HashMap<K, V> is Rust’s lookup table: you choose a key, and it maps that key to a value, like a real dictionary maps a word to its definition. HashSet<T> is the same idea with the values dropped — it only tracks which keys exist, for fast membership checks and de-duplication.

The idea, slowly

HashMap basics: insert, get, overwrite

use std::collections::HashMap;

fn main() {
    let mut ages: HashMap<&str, i32> = HashMap::new();
    ages.insert("Alice", 30);
    ages.insert("Bob", 25);

    // insert on an existing key OVERWRITES the old value
    ages.insert("Bob", 26);

    match ages.get("Alice") {
        Some(age) => println!("Alice is {}", age),
        None => println!("no Alice on file"),
    }

    for (name, age) in &ages {
        println!("{} is {}", name, age);
    }
}
  • use std::collections::HashMap; — unlike Vec, HashMap isn’t automatically in scope. Forget this line and you get cannot find type 'HashMap' in this scope.
  • .insert(key, value) adds a pair, or replaces the value if the key is already present — a map holds each key exactly once.
  • .get(key) returns Option<&V>Some(&value) if the key exists, None if it doesn’t — because a lookup by key can always miss.

The entry API: insert-or-update without a double lookup

The single most common HashMap pattern is “if this key exists, update its value; otherwise, insert a default.” Writing that with get/insert means checking the map twice. .entry(key).or_insert(default) does it in one step, and hands back a mutable reference you can modify directly:

use std::collections::HashMap;

fn main() {
    let text = "the quick fox jumps over the lazy fox";
    let mut counts: HashMap<&str, i32> = HashMap::new();

    for word in text.split_whitespace() {
        // "give me a mutable reference to word's count, inserting 0 first if missing"
        let count = counts.entry(word).or_insert(0);
        *count += 1;
    }

    let mut pairs: Vec<_> = counts.into_iter().collect();
    pairs.sort();
    println!("{:?}", pairs);
    // [("fox", 2), ("jumps", 1), ("lazy", 1), ("over", 1), ("quick", 1), ("the", 2)]
}

.entry(word) looks at the slot for word without removing it from the map. .or_insert(0) says “if that slot is empty, put 0 there first” — either way, it hands back &mut i32 pointing straight at the count for word. *count += 1 then dereferences and increments it. One line, one lookup, no separate “does it exist” branch. This is the idiomatic way to build counts, group items, or accumulate into a map — reach for entry before reaching for get + insert.

or_insert_with(|| ...) is the lazy version, for when the default is expensive to build (it only runs the closure if the key was actually missing); or_default() uses the value type’s Default implementation instead of a value you supply.

Custom key types need Eq and Hash

Any type can be a HashMap key if the compiler can hash it and compare it for equality — that’s what makes “look up this exact key” possible. Built-in types like &str and i32 already implement both. Your own struct needs to opt in explicitly:

use std::collections::HashMap;

#[derive(PartialEq, Eq, Hash, Debug)]
struct UserId(u32);

fn main() {
    let mut names: HashMap<UserId, &str> = HashMap::new();
    names.insert(UserId(1), "Alice");
    names.insert(UserId(2), "Bob");

    println!("{:?}", names.get(&UserId(1))); // Some("Alice")
}

#[derive(PartialEq, Eq, Hash)] asks the compiler to generate “compare field-by-field” and “hash field-by-field” for UserId automatically. Without it, HashMap<UserId, _> fails to compile with the trait bound 'UserId: Eq' is not satisfied (and the same for Hash) — the map genuinely cannot function as a lookup table without both. Eq is what lets it confirm “is this the same key,” and Hash is what lets it find the right bucket in the first place.

HashSet: membership and uniqueness, no values attached

A HashSet<T> is, conceptually, a HashMap<T, ()> — every key present, no values to go with them. Use it whenever the question is just “have I seen this?” or “give me only the distinct items”:

use std::collections::HashSet;

fn main() {
    let mut seen: HashSet<&str> = HashSet::new();

    for word in ["apple", "banana", "apple", "cherry", "banana"] {
        if seen.insert(word) {
            println!("new word: {}", word); // only prints on the first sighting
        }
    }

    println!("distinct count: {}", seen.len()); // 3
    println!("contains banana: {}", seen.contains("banana")); // true
}

.insert(value) returns true if the value was newly added and false if it was already present — that return value is what makes the “only print on first sighting” trick work in one line. .contains(value) is the O(1)-on-average membership check that’s the whole reason to reach for a HashSet instead of scanning a Vec with .contains() (which is O(n)).

Iteration order is not guaranteed

use std::collections::HashMap;

fn main() {
    let mut m = HashMap::new();
    m.insert("z", 1);
    m.insert("a", 2);
    m.insert("m", 3);

    for (k, _) in &m {
        print!("{} ", k); // order is unspecified — don't rely on it
    }
    println!();
}

HashMap and HashSet scatter keys across memory based on their hash, specifically so lookups are fast — there’s no relationship between insertion order and iteration order, and it can even change between runs of the same program (Rust randomizes the hash seed per-process as a security measure against denial-of-service attacks on the hashing). If you need sorted or insertion-ordered iteration, that’s not what HashMap is for — the next lesson covers BTreeMap, which keeps keys sorted by design.

Common mistakes

  • Forgetting use std::collections::HashMap;. It’s in the standard library but not in the default prelude. Missing the use gives cannot find type 'HashMap' in this scope.
  • Using get+insert where entry would do the job in one lookup. Not wrong, just more code and an extra map traversal — counts.entry(word).or_insert(0) replaces a multi-line if let/else almost every time.
  • A custom key type missing Eq/Hash. The error is a compile-time the trait bound '...: Eq' is not satisfied (or Hash). Add #[derive(PartialEq, Eq, Hash)] to the struct — it’s a compile error, not a runtime surprise, precisely so you catch it before shipping.
  • Assuming a HashMap preserves insertion order. It doesn’t, and the order can differ between runs of the same program. Don’t build logic that depends on it.
  • Calling .insert() on a HashMap and expecting an error on a duplicate key. It silently overwrites the old value instead. If you need to know whether a key already existed, check the return value of .insert() — it’s Some(old_value) on overwrite, None if the key was new.

More examples

Selling from a warehouse’s stock

A sale should reduce the count for exactly one item; .get_mut(key) hands back a mutable reference straight into the map, so there’s no separate lookup-then-write step.

use std::collections::HashMap;

fn main() {
    let mut stock: HashMap<&str, u32> = HashMap::new();
    stock.insert("widget", 50);
    stock.insert("gadget", 12);

    if let Some(count) = stock.get_mut("widget") {
        *count -= 3; // sold 3 widgets
    }

    println!("widgets left: {}", stock["widget"]);
    println!("gadgets left: {}", stock["gadget"]);
}

Grouping orders by customer

A flat list of orders becomes a per-customer order history the moment you group it — entry(...).or_insert_with(Vec::new) builds each customer’s list lazily as their orders come in.

use std::collections::HashMap;

fn main() {
    let orders = [
        ("alice", "book"),
        ("bob", "pen"),
        ("alice", "lamp"),
        ("carol", "mug"),
        ("bob", "notebook"),
    ];

    let mut by_customer: HashMap<&str, Vec<&str>> = HashMap::new();
    for (customer, item) in orders {
        by_customer.entry(customer).or_insert_with(Vec::new).push(item);
    }

    let mut names: Vec<_> = by_customer.keys().collect();
    names.sort();
    for name in names {
        println!("{}: {:?}", name, by_customer[name]);
    }
}

Blocking duplicate coupon codes

A checkout should honor each coupon once; HashSet::insert returning false on a repeat is exactly the signal needed to reject a code that’s already been redeemed.

use std::collections::HashSet;

fn main() {
    let mut redeemed: HashSet<&str> = HashSet::new();
    let attempts = ["SAVE10", "WELCOME", "SAVE10", "FREESHIP"];

    for code in attempts {
        if redeemed.insert(code) {
            println!("{} accepted", code);
        } else {
            println!("{} rejected: already used", code);
        }
    }
}

Feature flags with a safe fallback

Not every flag will have been set yet, so .get(key).unwrap_or(&false) treats a missing flag as “off” instead of crashing the app.

use std::collections::HashMap;

fn main() {
    let mut flags: HashMap<&str, bool> = HashMap::new();
    flags.insert("dark_mode", true);

    let dark_mode = *flags.get("dark_mode").unwrap_or(&false);
    let beta_search = *flags.get("beta_search").unwrap_or(&false);

    println!("dark_mode: {}", dark_mode);
    println!("beta_search: {}", beta_search);
}

Your turn

This program wants to track which Points have been visited, but it doesn’t compile.

use std::collections::HashMap;

struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let mut visited: HashMap<Point, bool> = HashMap::new();
    visited.insert(Point { x: 1, y: 2 }, true);

    println!("{}", visited.contains_key(&Point { x: 1, y: 2 }));
}
Show solution

Point has no Eq or Hash implementation, so the compiler can’t put it in a HashMap as a key — it wouldn’t know how to hash a Point into a bucket, or how to confirm two Points are “the same key.” The error is the trait bound 'Point: Eq' is not satisfied (and the same for Hash). Derive both:

use std::collections::HashMap;

#[derive(PartialEq, Eq, Hash)]
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let mut visited: HashMap<Point, bool> = HashMap::new();
    visited.insert(Point { x: 1, y: 2 }, true);

    println!("{}", visited.contains_key(&Point { x: 1, y: 2 })); // true
}

Hash needs Eq (not just PartialEq) alongside it — Eq promises the equality check is total and reflexive, which is what lets the map trust “same hash bucket, then compare equal” as proof of “same key.”

Quick check

Remember this

  • HashMap<K, V> looks up values by key; .insert(k, v) adds or overwrites, .get(k) returns Option<&V>.
  • map.entry(key).or_insert(default) is the standard insert-or-update pattern — one lookup instead of two.
  • Custom key types need #[derive(PartialEq, Eq, Hash)]; a missing derive is a compile error, not a runtime bug.
  • HashSet<T> tracks membership and uniqueness only — .insert() returns false if the value was already present.
  • Iteration order is unspecified and can change between runs — use BTreeMap/BTreeSet when order matters.

Go deeper

Next:

BTreeMap, VecDeque, and BinaryHeap

Intermediate · Abstractions

What & why

Vec and HashMap cover most everyday needs, but the standard library ships a few more collections built for specific access patterns. Picking the right one is about how you touch the data, not just what you store: do you need keys to come out sorted? Do you need to add and remove from both ends cheaply? Do you always want “the biggest one, right now”? BTreeMap/BTreeSet, VecDeque, and BinaryHeap each answer one of those questions well.

The idea, slowly

BTreeMap and BTreeSet: sorted keys, on purpose

A HashMap gives you fast lookups but scrambled iteration order. A BTreeMap<K, V> gives up a little lookup speed in exchange for keeping keys always in sorted order:

use std::collections::BTreeMap;

fn main() {
    let mut scores = BTreeMap::new();
    scores.insert("charlie", 3);
    scores.insert("alice", 9);
    scores.insert("bob", 5);

    // iterates in KEY order, every time, regardless of insertion order
    for (name, score) in &scores {
        println!("{}: {}", name, score);
    }
    // alice: 9
    // bob: 5
    // charlie: 3

    // range queries: "give me everything from bob onward"
    for (name, score) in scores.range("bob"..) {
        println!("from bob: {} = {}", name, score);
    }
}

HashMap is O(1) on average for get/insert; BTreeMap is O(log n) — technically slower, but the difference rarely matters in practice, and you get two things HashMap cannot offer: deterministic, sorted iteration, and .range(...) queries (“every key between X and Y”) which are impossible to express efficiently on a hash table. Reach for BTreeMap when you need the data in order — leaderboards, timestamps, anything you’d otherwise sort after the fact — and HashMap when you just need fast point lookups and don’t care about order.

BTreeSet<T> is to BTreeMap what HashSet is to HashMap: a sorted set of unique values, with the same .insert()/.contains() interface, iterated in ascending order.

Just like a HashMap key needs Eq + Hash, a BTreeMap key needs Ord (which itself requires Eq and PartialOrd) — the map needs to be able to say “is this key less than, equal to, or greater than that one” to keep itself sorted:

use std::collections::BTreeMap;

#[derive(PartialEq, Eq, PartialOrd, Ord, Debug)]
struct Version {
    major: u32,
    minor: u32,
}

fn main() {
    let mut releases = BTreeMap::new();
    releases.insert(Version { major: 1, minor: 2 }, "bugfix release");
    releases.insert(Version { major: 1, minor: 0 }, "initial release");
    releases.insert(Version { major: 2, minor: 0 }, "breaking release");

    for (v, note) in &releases {
        println!("{}.{}: {}", v.major, v.minor, note);
    }
    // 1.0: initial release
    // 1.2: bugfix release
    // 2.0: breaking release
}

#[derive(Ord)] compares struct fields in declaration ordermajor first, then minor as a tiebreaker — which is exactly the ordering you’d want for a version number.

VecDeque: cheap push/pop at both ends

Vec is fast at the back (push/pop are O(1)) but slow at the front — insert(0, x) or removing the first element has to shift every other element over, an O(n) operation. VecDeque (“double-ended queue”) is a ring buffer that makes both ends O(1):

use std::collections::VecDeque;

fn main() {
    let mut queue: VecDeque<i32> = VecDeque::new();

    queue.push_back(1);  // [1]
    queue.push_back(2);  // [1, 2]
    queue.push_front(0); // [0, 1, 2]  — O(1), unlike Vec::insert(0, _)

    println!("{:?}", queue);

    while let Some(front) = queue.pop_front() {
        print!("{} ", front); // 0 1 2 — first in, first out
    }
    println!();
}

Internally, a VecDeque is still backed by one contiguous allocation, but it’s treated as a ring: the logical “front” can start partway through the buffer and wrap around, so adding to the front never has to shift everything else. That makes it the natural fit for anything queue-shaped: a task queue processed in arrival order, a sliding window that drops old entries off the front while new ones arrive at the back, or a breadth-first-search frontier. VecDeque can also be indexed (queue[i]) and iterated like a Vec, so it isn’t purely a specialist tool — but plain Vec is still the better default when you only ever push and pop the back, since it has slightly less overhead.

BinaryHeap: always pop the biggest

A BinaryHeap<T> doesn’t keep its elements in any visible order — but calling .pop() always hands you back the largest remaining element, in O(log n):

use std::collections::BinaryHeap;

fn main() {
    let mut heap = BinaryHeap::new();
    heap.push(3);
    heap.push(7);
    heap.push(1);
    heap.push(5);

    while let Some(biggest) = heap.pop() {
        print!("{} ", biggest); // 7 5 3 1 — largest first, every time
    }
    println!();
}

This is a max-heap by default, and it’s the standard tool for a priority queue: “process the most urgent task next,” “always merge the two smallest lists first,” “keep the top-K largest values seen so far.” T needs Ord for the same reason BTreeMap keys do — the heap has to be able to compare elements to know which one is “biggest.”

A min-heap with std::cmp::Reverse

Sometimes you want the smallest element first instead — cheapest task, earliest deadline. BinaryHeap doesn’t have a separate “min mode”; instead, you flip the ordering by wrapping each value in std::cmp::Reverse, which swaps what “greater” means for that value:

use std::collections::BinaryHeap;
use std::cmp::Reverse;

fn main() {
    let mut min_heap = BinaryHeap::new();
    min_heap.push(Reverse(3));
    min_heap.push(Reverse(7));
    min_heap.push(Reverse(1));
    min_heap.push(Reverse(5));

    while let Some(Reverse(smallest)) = min_heap.pop() {
        print!("{} ", smallest); // 1 3 5 7 — smallest first
    }
    println!();
}

Reverse(x) is a thin wrapper whose Ord implementation is the opposite of x’s — so “the heap’s biggest Reverse value” is really “the smallest wrapped value.” Popping still calls the same .pop(); you just unwrap the Reverse to get the plain value back out. This is the idiomatic way to get min-heap behavior without a different collection type.

Common mistakes

  • Defaulting to VecDeque everywhere. If you only ever push and pop the back, plain Vec has less overhead and is the better default — reach for VecDeque specifically when you need the front to be cheap too.
  • Expecting BinaryHeap iteration to come out sorted. Only repeated .pop() guarantees largest-first order; iterating with for x in &heap (or .iter()) visits elements in unspecified internal order.
  • Pushing plain values when you wanted a min-heap. BinaryHeap::push(x) always feeds the max-heap ordering. Forgetting to wrap in Reverse(x) gives you largest-first when you wanted smallest-first — a logic bug, not a compile error, so it’s easy to miss.
  • Giving a BTreeMap or BinaryHeap a key/element type without Ord. The error is the trait bound '...: Ord' is not satisfied. For a custom struct, add #[derive(PartialEq, Eq, PartialOrd, Ord)] — all four are needed, since Ord itself depends on the other three.
  • Choosing BTreeMap purely out of habit. If you never need sorted iteration or range queries, HashMap’s average O(1) lookups are faster than BTreeMap’s O(log n) — don’t pay for ordering you don’t use.

More examples

A sorted, deduplicated SKU catalog

BTreeSet turns a raw scan of product codes into a clean, alphabetized export in one pass — duplicates just fail to re-insert, and iteration always comes out sorted.

use std::collections::BTreeSet;

fn main() {
    let mut skus = BTreeSet::new();
    skus.insert("SKU-042");
    skus.insert("SKU-007");
    skus.insert("SKU-042"); // duplicate, ignored
    skus.insert("SKU-019");

    println!("{} unique SKUs", skus.len());
    for sku in &skus {
        println!("{}", sku);
    }
}

A rolling average over sensor readings

A VecDeque capped at a fixed size is a cheap sliding window — push a new reading onto the back, pop the oldest off the front, and the average is always over just the last few.

use std::collections::VecDeque;

fn main() {
    let mut window: VecDeque<f64> = VecDeque::new();
    let readings = [21.0, 22.5, 23.0, 19.5, 20.0];

    for reading in readings {
        window.push_back(reading);
        if window.len() > 3 {
            window.pop_front(); // keep only the last 3 readings
        }
        let avg: f64 = window.iter().sum::<f64>() / window.len() as f64;
        println!("latest: {:.1}, rolling avg: {:.2}", reading, avg);
    }
}

A support ticket priority queue

Pushing (priority, description) tuples into a BinaryHeap means .pop() always hands back the most urgent ticket first, without sorting the whole queue every time one arrives.

use std::collections::BinaryHeap;

fn main() {
    let mut tickets = BinaryHeap::new();
    tickets.push((1, "typo on homepage"));
    tickets.push((5, "payment system down"));
    tickets.push((3, "slow page load"));

    while let Some((priority, issue)) = tickets.pop() {
        println!("[P{}] {}", priority, issue);
    }
}

Building a chronological event timeline

BTreeMap combines with the entry API just like HashMap does — grouping events under their timestamp and letting the map’s sorted keys put the whole log in time order for free.

use std::collections::BTreeMap;

fn main() {
    let events = [
        (930, "server restarted"),
        (215, "deploy started"),
        (930, "cache cleared"),
        (600, "backup completed"),
    ];

    let mut timeline: BTreeMap<u32, Vec<&str>> = BTreeMap::new();
    for (minute, event) in events {
        timeline.entry(minute).or_insert_with(Vec::new).push(event);
    }

    for (minute, events_at) in &timeline {
        println!("{:04}: {:?}", minute, events_at);
    }
}

Your turn

This program wants a version-sorted release log using BTreeMap, but it doesn’t compile.

use std::collections::BTreeMap;

struct Version {
    major: u32,
    minor: u32,
}

fn main() {
    let mut releases: BTreeMap<Version, &str> = BTreeMap::new();
    releases.insert(Version { major: 1, minor: 0 }, "initial release");
    releases.insert(Version { major: 1, minor: 2 }, "bugfix release");

    for (v, note) in &releases {
        println!("{}.{}: {}", v.major, v.minor, note);
    }
}
Show solution

Version has no ordering implementation, so BTreeMap — which must keep its keys sorted at all times — has no way to decide where a new Version belongs relative to the others. The error is the trait bound 'Version: Ord' is not satisfied. Derive the full comparison chain:

use std::collections::BTreeMap;

#[derive(PartialEq, Eq, PartialOrd, Ord)]
struct Version {
    major: u32,
    minor: u32,
}

fn main() {
    let mut releases: BTreeMap<Version, &str> = BTreeMap::new();
    releases.insert(Version { major: 1, minor: 0 }, "initial release");
    releases.insert(Version { major: 1, minor: 2 }, "bugfix release");

    for (v, note) in &releases {
        println!("{}.{}: {}", v.major, v.minor, note);
    }
    // 1.0: initial release
    // 1.2: bugfix release
}

Ord requires Eq and PartialOrd underneath it, so all four traits need deriving together. With them in place, #[derive(Ord)] compares major first and minor as the tiebreaker — field declaration order — which happens to be exactly the version ordering you want.

Quick check

Remember this

  • BTreeMap/BTreeSet keep keys sorted at all times (O(log n) operations) and support range queries — pick them over HashMap/HashSet when order matters.
  • Keys in a BTreeMap/BTreeSet need Ord (#[derive(PartialEq, Eq, PartialOrd, Ord)] on a custom struct).
  • VecDeque gives O(1) push/pop at both the front and back, unlike Vec which is O(n) at the front — the natural fit for queues and sliding windows.
  • BinaryHeap is a max-heap by default: .pop() always returns the largest remaining element.
  • Wrap values in std::cmp::Reverse to get min-heap behavior out of the same BinaryHeap.

Go deeper

Next:

Strings and str

Intermediate · Abstractions

What & why

Rust has two main text types and beginners bump into both on day one: String (text your program owns and can grow) and &str (a borrowed view into some text). Once you see why there are two, the endless “expected &str, found String” errors stop being mysterious and start being obvious.

The idea, slowly

Owned vs borrowed, one more time

You already met this split in the Ownership lessons, just with different types. Text is the same story:

  • String is a growable, heap-allocated buffer your variable owns. Think of it as a whiteboard you bought — it’s yours, you can write more on it, erase it, and when you’re done it gets thrown away.
  • &str (say “string slice”) is a borrowed look at text that already exists somewhere. Think of it as pointing at words on someone else’s whiteboard — you can read them, but you don’t own the board and can’t grow it.
fn main() {
    let owned: String = String::from("hello"); // owns a growable buffer
    let borrowed: &str = &owned;                // borrows a view of it

    println!("owned = {}", owned);
    println!("borrowed = {}", borrowed);
}

&owned borrows the String and hands you a &str looking into it. Nothing is copied; borrowed just points at the same letters owned holds.

String literals are already &str

Every time you type text in quotes, that’s a &str — it points into your compiled program, which stays alive the whole time it runs:

fn main() {
    let greeting = "hi there"; // type is &str, no String involved
    println!("{}", greeting);
}

So "hi" is a &str, and String::from("hi") turns that borrowed text into an owned String you can grow.

Growing a String

Only String can grow, because only String owns its buffer:

fn main() {
    let mut name = String::from("Shamir");
    name.push_str("ul");     // add several chars
    name.push('!');          // add one char (note single quotes)
    println!("{}", name);    // Shamirul!
}

push_str takes a &str (a borrowed piece of text to append), and push takes a single char. Try this on a plain &str and it won’t compile — a borrowed view has nothing of its own to grow.

The function-argument rule of thumb

This is the practical payoff. When a function just needs to read text, take &str. It’s the more flexible choice because both a String and a &str can be passed to it:

fn shout(text: &str) -> String {
    text.to_uppercase()
}

fn main() {
    let owned = String::from("hello");
    println!("{}", shout(&owned));  // pass a String by reference -> &str
    println!("{}", shout("world")); // pass a literal &str directly
}

shout accepts &str, so it works for owned strings (via &owned) and literals. If you’d written fn shout(text: String), you’d force every caller to hand over an owned String and give it away. Taking &str is friendlier. Take &str to read; return String when you build new text.

Length is in bytes, not letters

This one surprises everyone. Rust text is UTF-8, where some characters take more than one byte. .len() counts bytes:

fn main() {
    let word = "café";
    println!("bytes: {}", word.len());          // 5, not 4 — é is 2 bytes
    println!("chars: {}", word.chars().count()); // 4 actual characters
}

Because of this, you also can’t index text by numberword[0] is a compile error in Rust, on purpose, because “byte 0” and “character 0” aren’t always the same thing. To walk characters, use .chars().

Common mistakes

  • expected &str, found String (or vice versa). A function wanting &str won’t silently take a String. Pass &my_string to borrow it down to a &str. Going the other way, turn a &str into a String with .to_string() or String::from(...).
  • Trying to grow a &str. push_str/push need an owned buffer, so they only exist on String. The fix is to start from a String, or convert with .to_string().
  • Indexing text with [i]. s[0] doesn’t compile for strings because byte positions and character positions differ in UTF-8. Use .chars().nth(i) for a character, or slice by a known byte range.
  • Assuming .len() is the character count. It’s the byte count. For visible characters use .chars().count().
  • Taking String as a parameter when you only read it. This forces callers to give up ownership for no reason. Prefer &str for read-only text arguments.

More examples

Cleaning up user input

Form fields arrive messy — stray whitespace, inconsistent casing. Normalize them before you compare or store them.

fn main() {
    let raw_input = "   Alice@Example.com  \n";
    let clean = raw_input.trim().to_lowercase();
    println!("clean email: '{}'", clean);
}

Splitting a CSV-like line

Config files and simple data dumps are often just comma-separated fields. .split() plus .trim() handles the common case without pulling in a CSV crate.

fn main() {
    let line = "Ferris, 8, Crab";
    let fields: Vec<&str> = line.split(',').map(|f| f.trim()).collect();
    println!("{:?}", fields); // ["Ferris", "8", "Crab"]
}

Router-style path matching

A tiny web framework has to decide which handler owns a request path. starts_with/ends_with are the bread and butter of that decision.

fn main() {
    let path = "/api/users/42";

    if path.starts_with("/api/users/") {
        println!("route: get user");
    } else if path.ends_with(".json") {
        println!("route: serve json file");
    } else {
        println!("route: not found");
    }
}

Building a receipt line by line

Sometimes you don’t have all the text up front — you build it as you go, like assembling a shopping list into one printable line.

fn main() {
    let items = vec!["eggs", "milk", "bread"];
    let mut receipt = String::new();

    for item in &items {
        receipt.push_str(item);
        receipt.push_str(", ");
    }

    println!("{}", receipt); // eggs, milk, bread,
}

A helper that formats log lines

Utility functions like this get called with all kinds of text — owned Strings built at runtime, and &str literals. Taking &str parameters means one function serves both.

fn log_line(level: &str, message: &str) -> String {
    format!("[{}] {}", level.to_uppercase(), message)
}

fn main() {
    let msg = String::from("server started");
    println!("{}", log_line("info", &msg));
    println!("{}", log_line("warn", "disk almost full"));
}

Your turn

This function should return the text in uppercase, and be callable with both a String and a literal. It doesn’t compile. Fix the parameter type.

fn loud(text: String) -> String {
    text.to_uppercase()
}

fn main() {
    let name = String::from("rust");
    println!("{}", loud(&name)); // passing &name (a &str) — type mismatch
    println!("{}", loud("go"));  // passing a literal &str — type mismatch
}
Show solution

main passes borrowed text (&name and the literal "go"), both of which are &str. Make the function accept &str:

fn loud(text: &str) -> String {
    text.to_uppercase()
}

fn main() {
    let name = String::from("rust");
    println!("{}", loud(&name));
    println!("{}", loud("go"));
}

Accepting &str lets the function read either an owned String (borrowed with &) or a literal, without taking ownership.

Quick check

Remember this

  • String owns growable text; &str borrows a view of existing text.
  • String literals like "hi" are already &str.
  • Only String can grow (push_str, push) — a &str has nothing of its own to grow.
  • For read-only text arguments, take &str; it accepts both String (via &) and literals.
  • .len() is bytes, not characters; you can’t index text by number — use .chars().

Go deeper

Next:

Iterator basics

Intermediate · Abstractions

What & why

Think of an iterator as a vending machine: you press the button (.next()) and it hands you one item, or tells you it’s out. Every for loop you’ve ever written already uses one under the hood. This lesson pulls back the curtain on the Iterator trait itself, and on the single biggest source of iterator-flavored borrow-checker errors: the difference between .iter(), .iter_mut(), and .into_iter(). Chaining transformations like .map() and .filter() — the fun part — is the next lesson; here we’re building the foundation that makes those make sense.

The idea, slowly

The Iterator trait: one method, .next()

Everything iterable implements this trait, which boils down to a single required method:

#![allow(unused)]
fn main() {
trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
}
}

Call .next() and you get Some(item) if there’s more, or None once it’s exhausted. That’s the whole contract. Every adaptor and every for loop you’ll ever write is built on repeated calls to this one method.

You can drive an iterator by hand:

fn main() {
    let nums = vec![10, 20, 30];
    let mut iter = nums.iter(); // must be `mut` — next() needs &mut self

    println!("{:?}", iter.next()); // Some(10)
    println!("{:?}", iter.next()); // Some(20)
    println!("{:?}", iter.next()); // Some(30)
    println!("{:?}", iter.next()); // None — exhausted
}

What the compiler is thinking: next(&mut self) takes a mutable reference to the iterator so it can update wherever it tracks “how far along am I.” That’s why iter had to be declared mut — without it, the compiler refuses with cannot borrow iter as mutable.

for loops are .next() calls in a trench coat

Nobody actually writes manual .next() loops by hand — that’s exactly what for is sugar for. This:

fn main() {
    let nums = vec![10, 20, 30];
    for n in &nums {
        println!("{n}");
    }
}

desugars to roughly this:

fn main() {
    let nums = vec![10, 20, 30];
    let mut iter = (&nums).into_iter();
    while let Some(n) = iter.next() {
        println!("{n}");
    }
}

A for loop is just a while let Some(...) = iter.next() loop with the bookkeeping hidden. That single fact unlocks the next section: the only thing that changes between for x in &v, for x in &mut v, and for x in v is which flavor of into_iter() gets called — and each one hands back something different.

.iter() vs .iter_mut() vs .into_iter()

This is just ownership again, wearing an iterator costume:

MethodYieldsEffect on the collection
.iter()&T (a borrow)Untouched — still usable afterward
.iter_mut()&mut T (a mutable borrow)Untouched, but items can be changed in place
.into_iter()T (owned)Consumed — the collection is gone after
fn main() {
    let mut nums = vec![1, 2, 3];

    // .iter(): read-only borrow, nums survives
    for n in nums.iter() {
        print!("{n} ");
    }
    println!("- still have {} items", nums.len());

    // .iter_mut(): mutable borrow, change items in place
    for n in nums.iter_mut() {
        *n *= 10; // *n because n is &mut i32 — dereference to write through it
    }
    println!("{nums:?}"); // [10, 20, 30]

    // .into_iter(): takes ownership, nums is consumed
    for n in nums.into_iter() {
        print!("{n} ");
    }
    // nums.len() here would NOT compile — nums was moved
}

And the desugaring rule ties it all together: for x in &v calls .iter() under the hood, for x in &mut v calls .iter_mut(), and for x in v calls .into_iter(). That’s why for x in &v leaves v usable afterward, and for x in v doesn’t — you’re choosing the borrow mode the moment you write (or omit) the &.

Laziness: an iterator does nothing until something asks

Building an iterator — even one with a transformation attached — doesn’t run anything by itself. It’s a plan, not an action:

fn main() {
    let nums = vec![1, 2, 3];

    nums.iter().map(|n| n * 100); // just describes work — the compiler warns "unused `Map` that must be used"
    // (nothing was multiplied — nothing consumed the chain)

    let doubled: Vec<i32> = nums.iter().map(|n| n * 2).collect(); // NOW it runs
    println!("{doubled:?}"); // [2, 4, 6]
}

Nothing happens until something pulls values out — by calling .next() directly, by looping with for, or by handing the chain to a consumer like .collect(). That laziness is exactly what makes chaining .map(), .filter(), and friends cheap and composable, which is exactly what the next lesson, Iterator adaptors, covers in depth.

Common mistakes

  • Using for x in v when you still need v afterward. That form calls .into_iter(), which consumes v. If you need the collection again, loop over &v instead.
  • Forgetting mut on a manually-driven iterator. .next() takes &mut self; let iter = v.iter(); followed by iter.next() won’t compile without let mut iter = ....
  • Assuming an iterator “ran” just because you built it. v.iter().map(...) alone does nothing — Rust warns unused iterator that must be used. You need a for loop or a consumer.
  • Forgetting to dereference in .iter_mut(). The loop variable is &mut T, not T — writing n = 5 won’t compile, you need *n = 5 to write through the reference.
  • Reaching for .into_iter() out of habit. If you only need to read the items, .iter() is almost always the right call — it doesn’t give up the collection.

More examples

You often need to display a collection and keep using it afterward — printing a list shouldn’t be destructive.

fn main() {
    let guests = vec![String::from("Ferris"), String::from("Ada"), String::from("Grace")];

    for guest in &guests {
        println!("welcome, {guest}!");
    }

    println!("{} guests still in the list: {:?}", guests.len(), guests);
}

Shout every tag in place

Normalizing data in place — like uppercasing every tag before saving — is exactly what .iter_mut() is for: change items without rebuilding the whole collection.

fn main() {
    let mut tags = vec![String::from("rust"), String::from("wasm"), String::from("cli")];

    for tag in tags.iter_mut() {
        *tag = tag.to_uppercase();
    }

    println!("{:?}", tags); // ["RUST", "WASM", "CLI"]
}

Move pending orders into an archive

When you’re done with a collection and want to hand its contents to something else — like moving orders out of a “pending” list — .into_iter() transfers ownership instead of copying.

fn main() {
    let pending = vec![String::from("order-1"), String::from("order-2")];

    let archived: Vec<String> = pending.into_iter().collect();

    println!("{:?}", archived);
    // pending is gone now -- it was moved, not borrowed
}

Walk a HashMap of scores

Maps come up constantly for lookups — a for loop over &map gives you (key, value) pairs, one per entry.

use std::collections::HashMap;

fn main() {
    let mut scores = HashMap::new();
    scores.insert("Ferris", 92);
    scores.insert("Ada", 88);

    for (name, score) in &scores {
        println!("{name}: {score}");
    }
}

Process a job queue and stop on a signal

Driving an iterator by hand with .next() is useful when you need to react mid-loop — like bailing out the moment you see a stop signal.

fn main() {
    let jobs = vec!["resize", "compress", "STOP", "upload"];
    let mut iter = jobs.iter();

    while let Some(job) = iter.next() {
        if *job == "STOP" {
            println!("halting: stop signal received");
            break;
        }
        println!("running job: {job}");
    }
}

Your turn

This program should greet every name and then report how many names there were. It doesn’t compile.

fn main() {
    let names = vec![String::from("Ferris"), String::from("Rusty")];

    for name in names {
        println!("hello, {name}");
    }

    println!("we had {} names", names.len());
}
Show solution

for name in names calls .into_iter(), which takes ownership of names and consumes it — by the time names.len() runs, names is gone. The fix is to borrow instead of own, since the loop body only needs to read each name:

fn main() {
    let names = vec![String::from("Ferris"), String::from("Rusty")];

    for name in &names { // borrow: names survives the loop
        println!("hello, {name}");
    }

    println!("we had {} names", names.len()); // fine now
}

for name in &names calls .iter() under the hood, so names is only borrowed for the duration of the loop and is still valid afterward.

Quick check

Remember this

  • The Iterator trait boils down to one method: fn next(&mut self) -> Option<Self::Item>.
  • A for loop is sugar for calling .into_iter() once, then looping while let Some(x) = iter.next().
  • .iter() yields &T (borrow, collection stays usable), .iter_mut() yields &mut T (mutate in place), .into_iter() yields T (owned, consumes the collection).
  • for x in &v borrows (like .iter()); for x in &mut v mutably borrows (like .iter_mut()); for x in v consumes (like .into_iter()).
  • An iterator does nothing on its own — it needs .next(), a for loop, or a consumer like .collect() to actually run.

Go deeper

Next:

Iterator adaptors

Intermediate · Abstractions

What & why

An adaptor is a step in an iterator pipeline that describes a transformation — “double each item,” “keep only the even ones,” “pair each item with its index.” Adaptors snap together like an assembly line, and (as the previous lesson covered) none of them actually run anything: they build up a lazy plan until a consumer.collect(), .sum(), a for loop — pulls values through the whole chain. This is the idiomatic replacement for most hand-rolled index loops, and once you know the vocabulary, chains like .filter(...).map(...).collect() read like a sentence instead of a puzzle.

The idea, slowly

map: transform each item

.map() replaces every item with the result of a closure. It doesn’t touch anything until consumed:

fn main() {
    let nums = vec![1, 2, 3];
    let doubled: Vec<i32> = nums.iter().map(|n| n * 2).collect();
    println!("{doubled:?}"); // [2, 4, 6]
}

filter: keep only what passes a test

.filter() takes a closure returning bool and drops anything that returns false. The closure receives a reference to each item (&Item), so with .iter() on a Vec<i32> you’re comparing &i32, which usually means dereferencing:

fn main() {
    let nums = vec![1, 2, 3, 4, 5, 6];
    let evens: Vec<&i32> = nums.iter().filter(|n| **n % 2 == 0).collect();
    println!("{evens:?}"); // [2, 4, 6]
}

What the compiler is thinking: .iter() yields &i32. .filter()’s closure gets a reference to that item — &&i32 — so **n peels back both layers to reach the actual number. Forget one * and you’ll see cannot compare &i32 with integer; that error is your cue to dereference.

filter_map: filter and transform in one step

When “keep it, but also transform it” describes what you want, filter_map does both in one pass. Its closure returns Option<T>Some(value) keeps and unwraps, None drops:

fn main() {
    let inputs = vec!["3", "seven", "12", "oops", "9"];

    let numbers: Vec<i32> = inputs
        .iter()
        .filter_map(|s| s.parse().ok()) // Ok -> Some(n), Err -> None
        .collect();

    println!("{numbers:?}"); // [3, 12, 9]
}

Without filter_map you’d need .map(|s| s.parse()) followed by .filter(...) followed by unwrapping — one adaptor instead of three.

enumerate: pair each item with its index

fn main() {
    let letters = vec!['a', 'b', 'c'];
    for (i, letter) in letters.iter().enumerate() {
        println!("{i}: {letter}");
    }
    // 0: a
    // 1: b
    // 2: c
}

zip: walk two iterators together

.zip() pairs items positionally from two iterators. It stops as soon as the shorter one runs out:

fn main() {
    let names = vec!["Ferris", "Rusty"];
    let scores = vec![100, 87, 999]; // extra item, ignored

    let paired: Vec<(&&str, &i32)> = names.iter().zip(scores.iter()).collect();
    println!("{paired:?}"); // [("Ferris", 100), ("Rusty", 87)]
}

take / skip: slice the stream by count

fn main() {
    let nums = vec![1, 2, 3, 4, 5, 6];

    let first_three: Vec<&i32> = nums.iter().take(3).collect();
    let after_three: Vec<&i32> = nums.iter().skip(3).collect();

    println!("{first_three:?}"); // [1, 2, 3]
    println!("{after_three:?}"); // [4, 5, 6]
}

fold: build up one accumulated value

.fold(initial, |accumulator, item| ...) walks the whole iterator, carrying an accumulator through each step. .sum() is really just a specialized fold:

fn main() {
    let nums = vec![1, 2, 3, 4];

    let total = nums.iter().fold(0, |acc, n| acc + n);
    let joined = nums.iter().fold(String::new(), |mut acc, n| {
        acc.push_str(&n.to_string());
        acc.push(' ');
        acc
    });

    println!("{total}");  // 10
    println!("{joined}"); // "1 2 3 4 "
}

Consumers: collect, sum, count

These are what actually run a chain. .collect() is the most flexible — and the most likely to confuse the compiler, because it can build almost any collection. Tell it what to build either with a type annotation on the binding, or with turbofish syntax:

fn main() {
    let nums = vec![1, 2, 3, 4, 5];

    // Option A: type annotation on the binding
    let doubled: Vec<i32> = nums.iter().map(|n| n * 2).collect();

    // Option B: turbofish on collect itself
    let tripled = nums.iter().map(|n| n * 3).collect::<Vec<i32>>();

    let total: i32 = nums.iter().sum();
    let how_many = nums.iter().filter(|&&n| n > 2).count();

    println!("{doubled:?} {tripled:?} sum={total} big={how_many}");
}

.sum() adds everything up (needs a type it can add into, usually inferred). .count() just tallies how many items came through, regardless of their value.

A realistic pipeline

Here’s a chain doing real work: parse a batch of raw scores, keep the valid passing ones, number them, and format a report — four adaptors plus a consumer:

fn main() {
    let raw_scores = vec!["88", "42", "oops", "95", "59", "73"];

    let report: Vec<String> = raw_scores
        .iter()
        .filter_map(|s| s.parse::<i32>().ok()) // drop anything that isn't a number
        .filter(|&score| score >= 60)          // keep only passing scores
        .enumerate()                            // pair with a rank
        .map(|(i, score)| format!("#{}: {score}", i + 1)) // format for display
        .collect();

    for line in &report {
        println!("{line}");
    }
    // #1: 88
    // #2: 95
    // #3: 73
}

When a plain for loop reads better

Adaptor chains are great until they aren’t. Once a chain grows past roughly four or five steps, or mixes in side effects like I/O or logging, a for loop with a comment is often more readable — you can name intermediate values, step through it in a debugger one line at a time, and add a print statement without restructuring the whole chain. Prefer adaptors for straightforward transform/filter/collect work; reach for a for loop when the logic branches, has side effects, or the chain is fighting you.

Common mistakes

  • A chain with no consumer at the end. nums.iter().map(...) alone does nothing and the compiler warns unused Map that must be used. Add .collect(), .sum(), a for loop, or another consumer.
  • collect() without a target type. The compiler doesn’t know what to build — type annotations needed. Fix it with a type annotation (let v: Vec<i32> = ...) or turbofish (.collect::<Vec<i32>>()).
  • Forgetting filter/map closures receive references. Over .iter(), filter’s closure parameter is a reference to a reference (&&T) — you’ll often need *n or **n to compare or use the actual value.
  • Assuming .zip() pads the shorter iterator. It doesn’t — it silently truncates to the length of the shorter side. If lengths can differ and that matters, check lengths first or use a different strategy.
  • Chaining adaptors past the point of clarity. A 6-step chain that took you five minutes to write will take a teammate (or future you) five minutes to read. A for loop with a comment is not a downgrade.

More examples

Sum only the valid donations

Real input is messy — some entries won’t parse. filter_map lets you drop the bad ones and total the rest in a single pass, no intermediate Vec needed.

fn main() {
    let donations = vec!["25", "n/a", "100", "-", "40"];

    let total: i32 = donations.iter().filter_map(|d| d.parse::<i32>().ok()).sum();

    println!("total raised: ${total}"); // 165
}

Combine two shifts of readings with zip

zip isn’t just for display pairs — pairing up two same-length datasets and combining them element-wise (like adding two shifts’ sales) is a common use.

fn main() {
    let morning = vec![12, 8, 15];
    let evening = vec![5, 10, 7];

    let daily_totals: Vec<i32> = morning.iter().zip(evening.iter()).map(|(m, e)| m + e).collect();

    println!("{:?}", daily_totals); // [17, 18, 22]
}

Number the lines of a file

.enumerate() is exactly what a text editor or cat -n needs: pair each line with its position for display.

fn main() {
    let lines = vec!["fn main() {", "    println!(\"hi\");", "}"];

    for (num, line) in lines.iter().enumerate() {
        println!("{:>3} | {}", num + 1, line);
    }
}

Track the hottest reading with fold

fold isn’t limited to sums — any “carry a running answer through the whole list” problem fits, like tracking a running maximum.

fn main() {
    let temps = vec![68, 75, 71, 80, 66];

    let hottest = temps.iter().fold(i32::MIN, |max_so_far, &t| {
        if t > max_so_far { t } else { max_so_far }
    });

    println!("hottest reading: {hottest}"); // 80
}

Split a sorted list at a threshold

Given data that’s already sorted — like ages sorted ascending — take_while/skip_while split it at the first point a condition stops holding, without scanning the whole list twice by hand.

fn main() {
    let ages = vec![12, 15, 17, 18, 22, 30, 45];

    let minors: Vec<&i32> = ages.iter().take_while(|&&age| age < 18).collect();
    let adults: Vec<&i32> = ages.iter().skip_while(|&&age| age < 18).collect();

    println!("minors: {:?}", minors); // [12, 15, 17]
    println!("adults: {:?}", adults); // [18, 22, 30, 45]
}

Your turn

This should shout every name in uppercase and print the list. It doesn’t compile.

fn main() {
    let names = vec!["ferris", "rusty", "cargo"];

    let shout = names.iter().map(|s| s.to_uppercase()).collect();

    println!("{:?}", shout);
}
Show solution

.collect() can build many different collections, and here nothing tells it which one — the error is type annotations needed. Fix it with either a type annotation on shout or turbofish on collect itself:

fn main() {
    let names = vec!["ferris", "rusty", "cargo"];

    let shout: Vec<String> = names.iter().map(|s| s.to_uppercase()).collect();
    // or equivalently:
    // let shout = names.iter().map(|s| s.to_uppercase()).collect::<Vec<String>>();

    println!("{:?}", shout); // ["FERRIS", "RUSTY", "CARGO"]
}

Either form tells collect what to build; without one of them, the compiler has no way to pick a type.

Quick check

Remember this

  • Adaptors (map, filter, filter_map, enumerate, zip, take, skip, fold, …) are lazy — chain as many as you like before paying any cost.
  • Consumers (collect, sum, count, fold, for_each, for) are what actually pull values through and run the pipeline.
  • collect() needs a target type — a type annotation on the binding or turbofish, ::<Vec<_>>().
  • zip stops at the shorter of its two iterators; filter/map closures over .iter() receive references, so dereference to compare or use the value.
  • More than ~4-5 chained adaptors, or any side effects, often read worse than a plain for loop with a comment — clarity beats cleverness.

Go deeper

Next:

Closures

Intermediate · Abstractions

What & why

A closure is a little function you write right where you use it, without giving it a name — and it can remember variables from the surrounding code. You’ve already seen them living inside iterator chains (.map(|n| n * 2)). This lesson slows down and explains what those |...| bars actually are.

The idea, slowly

A function with no name

Compare a normal function to a closure that does the same thing:

fn main() {
    // normal named function
    fn double_fn(x: i32) -> i32 {
        x * 2
    }

    // closure stored in a variable
    let double_cl = |x: i32| x * 2;

    println!("{}", double_fn(5)); // 10
    println!("{}", double_cl(5)); // 10
}

The closure is |x: i32| x * 2. Read it as:

  • |x: i32| — the parameter list, but with pipes | | instead of parentheses. Here it takes one i32 called x.
  • x * 2 — the body. A one-expression closure doesn’t need { } or a return; the last expression is the result. (You can use braces for multi-line bodies: |x| { let y = x + 1; y * 2 }.)

Rust can usually figure out the types, so you’ll often see them dropped: let double_cl = |x| x * 2;. The types get inferred from how you call it.

The superpower: capturing the environment

Here’s what makes a closure different from a plain function — it can use variables from the code around it, without you passing them in:

fn main() {
    let tax = 0.1;

    // this closure "captures" tax from the surrounding scope
    let with_tax = |price: f64| price + price * tax;

    println!("{}", with_tax(100.0)); // 110
    println!("{}", with_tax(50.0));  // 55
}

with_tax uses tax even though tax was never passed in as an argument. The closure captured it from the environment. A normal fn cannot do this — a top-level function only sees its own parameters. This is exactly why closures shine in iterator chains: .filter(|n| *n > threshold) can reach out and grab your local threshold.

What the compiler is thinking: “This closure mentions tax, which lives outside it. I need to keep tax available for the closure to use.” It quietly bundles the captured variable together with the code.

How a closure captures: borrow, or move

By default a closure captures by borrowing — it just peeks at the variable, like &:

fn main() {
    let name = String::from("Rust");

    let greet = || println!("Hello, {}", name); // borrows name
    greet();
    greet();

    println!("still have: {}", name); // name is still usable — only borrowed
}

But sometimes you need the closure to own what it captures — especially if the closure will outlive the current scope (for example, handed to a thread). You force that with the move keyword:

fn main() {
    let name = String::from("Rust");

    let greet = move || println!("Hello, {}", name); // takes ownership of name
    greet();
    // println!("{}", name); // ERROR now: name was moved into the closure
}

move tells the closure “take these captured variables with you.” After that, the original variable is gone from the outer scope — same move rules you learned in Ownership, just applied to captured values.

Passing a closure to a function

Functions can accept closures as arguments. You describe “a thing I can call” with the Fn trait family:

fn apply_twice<F: Fn(i32) -> i32>(f: F, start: i32) -> i32 {
    f(f(start))
}

fn main() {
    let add_three = |x| x + 3;
    println!("{}", apply_twice(add_three, 10)); // 10 -> 13 -> 16
}

F: Fn(i32) -> i32 reads as “F is some callable that takes an i32 and returns an i32.” That’s a trait bound (from the Generics lesson), and it lets apply_twice accept any matching closure. The three closure traits are Fn (just reads captured values), FnMut (changes them), and FnOnce (consumes them) — for most beginner code, Fn is all you need to recognize.

Common mistakes

  • Pipes vs parentheses. Closure parameters go between | |, not ( ). Writing (x) x * 2 isn’t a closure. The shape is |params| body.
  • Using a captured variable after move. Once you write move ||, captured owning values (like a String) are moved into the closure; touching the original afterward gives value moved. Only add move when you actually need the closure to own its captures.
  • Expecting a closure to work in a place a plain fn is required. Some very low-level spots want a bare function pointer, not a capturing closure. If a closure captures nothing, it can coerce to a function pointer; if it captures, it can’t. The error mentions expected fn pointer, found closure.
  • Over-stuffing a closure. A closure with twenty lines of logic is harder to read than a named function. Keep closures short and near their use; promote big logic to a real fn.
  • Forgetting the return type/expression rule. In |x| x + 1, there’s no ; after x + 1 — adding one (|x| { x + 1; }) turns it into a closure that returns nothing (()), which usually breaks the caller.

More examples

Sort products by a custom key

sort_by_key takes a closure that picks the value to sort by — here, sorting a product list by price instead of name.

fn main() {
    let mut products = vec![("mouse", 25), ("keyboard", 60), ("mat", 10)];

    products.sort_by_key(|&(_, price)| price);

    println!("{:?}", products); // [("mat", 10), ("mouse", 25), ("keyboard", 60)]
}

A counter closure that remembers state

A closure that mutates a captured variable across calls — like a request counter or ID generator — needs FnMut, which is why it’s stored in a mut binding.

fn main() {
    let mut count = 0;
    let mut tick = || {
        count += 1;
        count
    };

    println!("{}", tick()); // 1
    println!("{}", tick()); // 2
    println!("{}", tick()); // 3
}

A function that builds a closure

Sometimes you want a family of closures — like discount calculators for different percentages. A function can return one, tailored by its arguments.

fn make_discounter(percent: f64) -> impl Fn(f64) -> f64 {
    move |price| price - price * percent / 100.0
}

fn main() {
    let ten_percent_off = make_discounter(10.0);
    println!("{}", ten_percent_off(200.0)); // 180
    println!("{}", ten_percent_off(50.0));  // 45
}

Pass a closure as a callback

Handing a closure into a function as “what to do with each item” is a common pattern for things like processing orders one at a time.

fn process_orders(orders: &[&str], on_each: impl Fn(&str)) {
    for order in orders {
        on_each(order);
    }
}

fn main() {
    let orders = ["order-1", "order-2", "order-3"];
    process_orders(&orders, |o| println!("shipping {o}"));
}

Filter with a captured threshold

A closure that reaches out and grabs a local variable — like a reorder threshold — is what makes .filter() so handy for one-off business rules.

fn main() {
    let inventory = vec![5, 12, 3, 20, 8];
    let low_stock_limit = 10;

    let low_stock: Vec<&i32> = inventory.iter().filter(|&&qty| qty < low_stock_limit).collect();

    println!("reorder these: {:?}", low_stock); // [5, 3, 8]
}

Your turn

This should build a closure that adds a captured bonus to any score, then apply it. It doesn’t compile — the closure syntax is wrong.

fn main() {
    let bonus = 5;

    let add_bonus = (score) score + bonus;

    println!("{}", add_bonus(10));
    println!("{}", add_bonus(20));
}
Show solution

Closure parameters go inside pipes | |, not parentheses:

fn main() {
    let bonus = 5;

    let add_bonus = |score| score + bonus; // pipes, and it captures bonus

    println!("{}", add_bonus(10)); // 15
    println!("{}", add_bonus(20)); // 25
}

The closure captures bonus from the surrounding scope, so you never pass it in explicitly.

Quick check

Remember this

  • A closure is an unnamed function written inline: |params| body.
  • Its superpower is capturing variables from the surrounding scope — a plain fn can’t do that.
  • By default closures borrow what they capture; add move to make them own it (needed when the closure outlives the scope, e.g. threads).
  • Functions accept closures via the Fn / FnMut / FnOnce trait bounds.
  • Keep closures small; promote big logic to a named function.

Go deeper

Next:

Option and Result

Intermediate · Abstractions

What & why

Most languages let a function secretly fail — throw an exception, return null, crash — and you find out at 2am. Rust makes failure part of the return type, so the compiler forces you to deal with it before your program runs. Option<T> says “there might not be a value”; Result<T, E> says “this might fail, and here’s why.” Once you’re comfortable with match on both, the real payoff is their combinators.map(), .and_then(), .unwrap_or(), .ok_or() — which let you chain transformations without unwrapping early and re-wrapping by hand.

The idea, slowly

Option: maybe there’s a value, maybe not

Option<T> is Rust’s honest answer to “this might have nothing.” It has exactly two shapes: Some(value) (there’s a value) or None (there isn’t). This is what replaces null — but unlike null, you can’t accidentally use it as if a value were there, because the compiler makes you check.

fn main() {
    let names = vec!["Alice", "Bob"];

    match names.get(5) {         // .get returns Option: Some or None
        Some(name) => println!("found {}", name),
        None => println!("nobody at index 5"),
    }
}

match forces you to write both branches — the found case and the empty case. Forget one and the compiler refuses to build, saying the match isn’t exhaustive. That’s Rust removing an entire category of “I forgot to check for null” bugs.

Result: it worked, or here’s why it failed

Result<T, E> is for operations that can fail with a reason. Its two shapes are Ok(value) (success, here’s the result) and Err(problem) (failure, here’s what went wrong). Parsing text into a number is a classic example — it fails if the text isn’t a number:

fn main() {
    let good: Result<i32, _> = "42".parse();
    let bad: Result<i32, _> = "oops".parse();

    match good {
        Ok(n) => println!("parsed {}", n),
        Err(e) => println!("failed: {}", e),
    }

    match bad {
        Ok(n) => println!("parsed {}", n),
        Err(e) => println!("failed: {}", e), // this one runs
    }
}

Again match makes you handle both outcomes. The Err carries a real error value describing what happened, not just a silent false.

unwrap and expect: the “I’m sure” shortcuts (careful!)

Sometimes you just want the value and are willing to crash if it’s missing. unwrap() and expect(...) do that — they hand back the inner value on success, and panic (crash the program) on None/Err:

fn main() {
    let n: i32 = "42".parse().unwrap(); // fine: it IS a number
    println!("{}", n);

    let ok = "7".parse::<i32>().expect("should be a number");
    println!("{}", ok);
    // "oops".parse::<i32>().unwrap(); // would CRASH the program
}

These are handy in tiny examples and tests. In real programs, reaching for unwrap everywhere means “crash on any problem,” which is rarely what you want. expect is slightly better than unwrap because its message tells you which unwrap blew up — but neither is a substitute for actually handling the failure.

Combinators: transforming without unwrapping

Writing match every time you touch an Option/Result gets verbose, especially when all you want to do is “if there’s a value, transform it” or “if it failed, use a default.” Both types have methods for exactly this — you stay inside the Option/Result “container” the whole time instead of unwrapping, checking, and re-wrapping by hand.

.map() transforms the value inside Some/Ok, leaving None/Err untouched:

fn main() {
    let price: Option<i32> = Some(10);
    let with_tax = price.map(|p| p * 110 / 100);
    println!("{:?}", with_tax); // Some(11)

    let missing: Option<i32> = None;
    let still_missing = missing.map(|p| p * 110 / 100);
    println!("{:?}", still_missing); // None — map never runs the closure
}

.and_then() is for when the next step is itself fallible — the closure you pass must return an Option/Result, not a bare value. This chains fallible steps without nesting Option<Option<T>>:

fn half_if_even(n: i32) -> Option<i32> {
    if n % 2 == 0 { Some(n / 2) } else { None }
}

fn main() {
    let x = Some(8).and_then(half_if_even).and_then(half_if_even);
    println!("{:?}", x); // Some(2)  (8 -> 4 -> 2)

    let y = Some(7).and_then(half_if_even);
    println!("{:?}", y); // None — 7 is odd, chain stops
}

What the compiler is thinking: with .map(f), it expects f: T -> U and wraps the result back in Some/Ok for you. With .and_then(f), it expects f: T -> Option<U> (or Result<U, E>) and does not re-wrap — if your closure returns a bare value instead of Some(value), that’s a type mismatch, not a missing wrap.

.unwrap_or(default) and .unwrap_or_else(|| ...) get you a plain value out, no panic risk — you supply a fallback instead:

fn main() {
    let a: Option<i32> = None;
    println!("{}", a.unwrap_or(0)); // 0

    let b: Result<i32, String> = Err("bad input".to_string());
    println!("{}", b.unwrap_or_else(|_e| -1)); // -1, computed lazily from the error
}

Use .unwrap_or(x) when the fallback is cheap to compute up front; use .unwrap_or_else(|| ...) when computing it is expensive or needs the error value — the closure only runs on the failure path.

.ok_or(err) turns an Option into a Result by supplying the error to use for None. .ok() goes the other way, turning a Result into an Option and throwing away the error:

fn main() {
    let found: Option<i32> = None;
    let as_result: Result<i32, &str> = found.ok_or("not found");
    println!("{:?}", as_result); // Err("not found")

    let parsed: Result<i32, _> = "42".parse();
    let as_option: Option<i32> = parsed.ok();
    println!("{:?}", as_option); // Some(42)
}

.filter() on Option keeps Some(value) only if a predicate returns true; otherwise it becomes None:

fn parse_positive(text: &str) -> Option<i32> {
    text.parse::<i32>().ok().filter(|&n| n > 0)
}

fn main() {
    println!("{:?}", parse_positive("21")); // Some(21)
    println!("{:?}", parse_positive("-5")); // None — filtered out
    println!("{:?}", parse_positive("oops")); // None — parse failed first
}

Chained together, combinators read like a pipeline: text.parse::<i32>().ok().filter(|&n| n > 0).map(|n| n * 2).unwrap_or(0) — parse it, drop it if parsing failed or it’s not positive, double it, or fall back to 0. No match, no intermediate variables.

Common mistakes

  • unwrap() in real code. It crashes the whole program on the first None/Err. Fine for a quick test; risky in anything a user runs. Prefer match, if let, a combinator, or ? (next lesson).
  • Passing .and_then() a closure that returns a bare value instead of Option/Result. .and_then(|n| n + 1) doesn’t compile — the closure must return Some(n + 1) (or Ok/Err). If your closure just transforms the value, you wanted .map(), not .and_then().
  • Reaching for .and_then() when .map() would do. If your closure can’t fail, .map() is simpler and doesn’t need you to wrap the result.
  • Confusing Option with Result. Use Option when something is simply absent (no error to report); use Result when there’s a reason it failed you want to carry. .ok_or() and .ok() exist precisely because this choice sometimes needs to change mid-pipeline.
  • Ignoring a Result entirely. Rust warns if you drop a Result on the floor (unused Result that must be used). Handle it, propagate it, or explicitly let _ = ... if you truly mean to ignore it.

More examples

Chain a parse, then a division that can also fail

Two fallible steps in a row — parsing text, then dividing (which fails on zero) — chain naturally with .and_then() instead of nested match.

fn safe_divide(a: i32, b: i32) -> Option<i32> {
    if b == 0 { None } else { Some(a / b) }
}

fn main() {
    let result = "20".parse::<i32>().ok().and_then(|n| safe_divide(n, 4));
    println!("{:?}", result); // Some(5)

    let by_zero = "20".parse::<i32>().ok().and_then(|n| safe_divide(n, 0));
    println!("{:?}", by_zero); // None
}

Fall back to a sensible default for a missing setting

When a config value is simply absent, .unwrap_or_default() grabs the type’s default (0 for numbers, "" for strings) instead of making you spell out a fallback.

fn main() {
    let raw_config: Option<u32> = None; // key was missing from the config file
    let timeout_secs: u32 = raw_config.unwrap_or_default();

    println!("timeout: {timeout_secs}s"); // 0 -- u32's default
}

Handle one special case by hand, ? for the rest

Not every fallible step deserves the same treatment — here an empty cart is handled explicitly, while everything else flows through ? normally.

fn checkout_total(cart_total: &str) -> Result<i32, String> {
    let total: i32 = match cart_total {
        "0" => return Err("cart is empty".to_string()), // one special case, by hand
        text => text.parse().map_err(|_| "bad total".to_string())?, // normal path uses ?
    };
    Ok(total + 5) // add flat shipping
}

fn main() {
    println!("{:?}", checkout_total("40"));   // Ok(45)
    println!("{:?}", checkout_total("0"));    // Err("cart is empty")
    println!("{:?}", checkout_total("oops")); // Err("bad total")
}

Reject a value that fails a format check

.filter() on Option isn’t just for numeric ranges — it works for any predicate, like rejecting a username that contains spaces.

fn main() {
    let username: Option<&str> = Some("ferris the crab");

    let valid = username.filter(|name| !name.contains(' '));

    println!("{:?}", valid); // None -- contains a space
}

Build a receipt line with chained .map()s

When each step can’t fail, chaining multiple .map() calls reads like a small pipeline — compute a price, then format it, all without unwrapping in between.

fn main() {
    let quantity: Option<i32> = Some(3);

    let receipt_line = quantity
        .map(|q| q * 25)              // price per item is $25
        .map(|total| format!("${total}"));

    println!("{:?}", receipt_line); // Some("$75")
}

Your turn

This function should parse a price string, apply a 10% discount, and fall back to 0 if parsing fails — but it doesn’t compile.

fn discounted_price(text: &str) -> i32 {
    text.parse::<i32>()
        .and_then(|n| n * 90 / 100)
        .unwrap_or(0)
}

fn main() {
    println!("{}", discounted_price("100")); // want: 90
    println!("{}", discounted_price("oops")); // want: 0
}
Show solution

The closure passed to .and_then() must return a Result (since .parse() returns Result<i32, ParseIntError>), but n * 90 / 100 is a bare i32. Since the transformation here can’t fail, the right combinator is .map(), which wraps the output for you:

fn discounted_price(text: &str) -> i32 {
    text.parse::<i32>()
        .map(|n| n * 90 / 100)
        .unwrap_or(0)
}

fn main() {
    println!("{}", discounted_price("100")); // 90
    println!("{}", discounted_price("oops")); // 0
}

.and_then() is for chaining another fallible step (its closure must itself return Result/Option). .map() is for a plain transformation of the success value. Mixing them up is a type error, not a logic bug — the compiler catches it immediately.

Quick check

Remember this

  • Option<T> = Some(v) or None — a value might be missing (Rust’s safe replacement for null).
  • Result<T, E> = Ok(v) or Err(e) — an operation might fail with a reason.
  • match forces you to handle every case, so you can’t forget the failure path.
  • .map(f) transforms the success value (f returns a plain value); .and_then(f) chains another fallible step (f returns Option/Result).
  • .unwrap_or(default) / .unwrap_or_else(|| ...) get a plain value out with a fallback; .ok_or(err) and .ok() convert between Option and Result; .filter() turns Some into None when a predicate fails.
  • unwrap() / expect() grab the value but panic on failure; use them sparingly, mostly in tests and quick scripts.

Go deeper

Next:

The ? operator

Intermediate · Abstractions

What & why

Writing a match at every fallible step — parse this, or return the error; read that, or return the error — gets tedious fast, and the boilerplate drowns out the actual logic. The ? operator is the shortcut: put it after something that returns Result (or Option), and it means “if this succeeded, give me the value; if it failed, stop and return that failure from my function right now.” It only works inside a function whose own return type is Result/Option, and — its real superpower — it can convert between different error types along the way.

The idea, slowly

? is sugar for a match

Take this function without ?:

use std::num::ParseIntError;

fn double_from_text_verbose(text: &str) -> Result<i32, ParseIntError> {
    let n = match text.parse::<i32>() {
        Ok(value) => value,
        Err(e) => return Err(e), // bail out immediately with the error
    };
    Ok(n * 2)
}

fn main() {
    println!("{:?}", double_from_text_verbose("10"));   // Ok(20)
    println!("{:?}", double_from_text_verbose("nope")); // Err(ParseIntError { .. })
}

? collapses that entire match into one character:

use std::num::ParseIntError;

fn double_from_text(text: &str) -> Result<i32, ParseIntError> {
    let n = text.parse::<i32>()?; // on error, return the Err right here
    Ok(n * 2)                     // on success, continue
}

fn main() {
    println!("{:?}", double_from_text("10"));   // Ok(20)
    println!("{:?}", double_from_text("nope")); // Err(ParseIntError { .. })
}

What the compiler is thinking: at the ?, it inserts “check: is this Err? If so, return Err(...) right now — converting the error type if needed (more on that below). Otherwise, unwrap the Ok and keep going.” Notice the success path still wraps the answer in Ok(...)? only handles the early-return side; the function’s normal return still needs to produce a Result.

Why ? only works in a Result/Option-returning function

?’s early return has to return something from the enclosing function — specifically, an Err (or None). If the function doesn’t return Result/Option, there’s nowhere for that early return to go, and the compiler refuses:

error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option`

The fix is always the same shape: give the function a Result<T, E> (or Option<T>) return type so ? has something to return early with.

The hidden superpower: ? converts error types via From

Real functions often call into several things that fail with different error types. ? doesn’t just return the error as-is — it calls From::from on it, converting it into whatever error type the function declares. As long as From<SourceError> for MyError exists, ? uses it automatically:

use std::error::Error;

fn parse_env_number(key: &str) -> Result<i32, Box<dyn Error>> {
    let text = std::env::var(key)?; // VarError converts into Box<dyn Error>
    let n: i32 = text.parse()?;     // ParseIntError converts into Box<dyn Error>
    Ok(n)
}

fn main() {
    match parse_env_number("PORT") {
        Ok(n) => println!("port: {n}"),
        Err(e) => println!("couldn't read PORT: {e}"),
    }
}

Here std::env::var fails with VarError and .parse() fails with ParseIntError — two unrelated types — but both ?s work because Box<dyn Error> has a blanket From impl for any type implementing std::error::Error. The function only has to declare one error type; ? does the conversion at each call site. (The next two lessons build on exactly this: writing your own error type with From impls, and letting thiserror/anyhow generate them for you.)

? works on Option too

The same operator works in a function returning Option: on Some, it unwraps; on None, it returns None immediately.

fn first_upper_char(text: &str) -> Option<char> {
    let c = text.chars().next()?; // None if text is empty — return None right here
    Some(c.to_ascii_uppercase())
}

fn main() {
    println!("{:?}", first_upper_char("rust")); // Some('R')
    println!("{:?}", first_upper_char(""));      // None
}

main can return a Result too

Because ? needs a Result/Option-returning function to work in, and you’ll often want to use ? at the top level, fn main is allowed to return Result<(), E>:

fn main() -> Result<(), std::num::ParseIntError> {
    let n: i32 = "123".parse()?;
    println!("got {}", n);
    Ok(())
}

Ok(()) means “succeeded, with no meaningful value” — () is Rust’s empty type. If a ? inside main hits an error, the program exits with a nonzero status and prints the error using its Debug output.

Common mistakes

  • Using ? in a function that doesn’t return Result/Option. The error is the ? operator can only be used in a function that returns Result or Option. Change the function’s return type, or handle the error with match instead.
  • Forgetting to wrap the success value in Ok(...). In a -> Result<...> function, the happy path must return Ok(value), not a bare value. ? only rewrites the error path; the normal return is still your job.
  • Using ? across two error types with no From impl between them. If your function returns Result<T, ParseIntError> but you ? on something that fails with std::io::Error, the compiler can’t find a conversion and refuses to build. Either widen the return type (e.g. to Box<dyn Error>), or write the From impl yourself (next lesson).
  • Expecting ? to work in a closure the same way it does in the enclosing function. ? returns from the nearest enclosing function — inside a closure, that’s the closure, not main. If the closure’s return type isn’t Result/Option too, it won’t compile.

More examples

Chain three fallible steps in one function

Real functions rarely stop at one ? — computing an order total might mean parsing a quantity, a price, and a tax figure, each of which can fail on its own.

use std::num::ParseIntError;

fn total_cost(qty_text: &str, price_text: &str, tax_text: &str) -> Result<i32, ParseIntError> {
    let qty: i32 = qty_text.parse()?;     // step 1
    let price: i32 = price_text.parse()?; // step 2
    let tax: i32 = tax_text.parse()?;     // step 3
    Ok(qty * price + tax)
}

fn main() {
    println!("{:?}", total_cost("3", "20", "5"));   // Ok(65)
    println!("{:?}", total_cost("3", "oops", "5")); // Err(...)
}

Convert a library error into your own error type

Instead of erasing everything into Box<dyn Error>, a small app-specific error enum with a From impl lets ? convert automatically while keeping a concrete, matchable type.

use std::num::ParseIntError;

#[derive(Debug)]
enum ConfigError {
    BadNumber(ParseIntError),
}

impl From<ParseIntError> for ConfigError {
    fn from(e: ParseIntError) -> Self {
        ConfigError::BadNumber(e)
    }
}

fn read_port(text: &str) -> Result<i32, ConfigError> {
    let port: i32 = text.parse()?; // ParseIntError auto-converts via From
    Ok(port)
}

fn main() {
    println!("{:?}", read_port("8080")); // Ok(8080)
    println!("{:?}", read_port("nope")); // Err(BadNumber(...))
}

? inside a helper, called from main

A helper function’s Result doesn’t stop at its own boundary — call it with ? from another Result-returning function, including main itself.

fn parse_pair(a: &str, b: &str) -> Result<(i32, i32), std::num::ParseIntError> {
    Ok((a.parse()?, b.parse()?))
}

fn main() -> Result<(), std::num::ParseIntError> {
    let (x, y) = parse_pair("4", "5")?; // helper's Result propagates into main
    println!("sum: {}", x + y);
    Ok(())
}

? on an Option, inside a function returning Option

Extracting a filename’s extension is a classic “might not exist” lookup — ? on Option bails out cleanly the moment there’s nothing to find.

fn file_extension(name: &str) -> Option<&str> {
    let dot_index = name.rfind('.')?; // None if there's no dot at all
    Some(&name[dot_index + 1..])
}

fn main() {
    println!("{:?}", file_extension("report.pdf")); // Some("pdf")
    println!("{:?}", file_extension("README"));      // None
}

Stop a batch job at the first bad value

Inside a loop, ? still bails out of the whole function on the first failure — handy for validating a batch of input where one bad value should stop everything.

fn parse_all(values: &[&str]) -> Result<Vec<i32>, std::num::ParseIntError> {
    let mut out = Vec::new();
    for v in values {
        out.push(v.parse::<i32>()?); // bails out of the whole function on the first bad value
    }
    Ok(out)
}

fn main() {
    println!("{:?}", parse_all(&["1", "2", "3"]));      // Ok([1, 2, 3])
    println!("{:?}", parse_all(&["1", "oops", "3"]));   // Err(...)
}

Your turn

This function should read a PORT environment variable and parse it as a number, but it doesn’t compile.

use std::num::ParseIntError;

fn parse_env_number(key: &str) -> Result<i32, ParseIntError> {
    let text = std::env::var(key)?; // env::var fails with VarError, not ParseIntError
    let n: i32 = text.parse()?;
    Ok(n)
}

fn main() {
    println!("{:?}", parse_env_number("PORT"));
}
Show solution

std::env::var fails with std::env::VarError, but the function’s declared error type is ParseIntError. ? tries to convert the error via From::from, but there’s no From<VarError> for ParseIntError — so the compiler rejects it with a type mismatch on the ?.

The simplest fix is to widen the return type to something both error types can convert into, like Box<dyn std::error::Error>:

use std::error::Error;

fn parse_env_number(key: &str) -> Result<i32, Box<dyn Error>> {
    let text = std::env::var(key)?; // VarError -> Box<dyn Error>
    let n: i32 = text.parse()?;     // ParseIntError -> Box<dyn Error>
    Ok(n)
}

fn main() {
    println!("{:?}", parse_env_number("PORT"));
}

Both VarError and ParseIntError implement std::error::Error, and there’s a blanket From impl that converts any such type into Box<dyn Error>, so both ?s now compile. (The next lesson shows the alternative: a custom error enum with explicit From impls, which keeps the concrete error type instead of erasing it into a trait object.)

Quick check

Remember this

  • expr? means: on success, give me the inner value; on failure, return early from this function with the error.
  • ? only compiles inside a function that itself returns Result or Option — there’s nowhere else for the early return to go.
  • ? calls From::from on the error, so a function can return one error type while ?-ing through several different underlying error types — as long as a From conversion exists (or the target is Box<dyn Error>, which accepts anything).
  • The happy path still needs an explicit Ok(value) (or Some(value)) — ? only handles the early-return side.
  • fn main() -> Result<(), E> lets you use ? directly in main.

Go deeper

Next:

Custom error types

Intermediate · Abstractions

What & why

A real function usually has more than one way to fail. parse_env_number from the last lesson could fail because the variable is missing or because the text isn’t a number — two genuinely different problems a caller might want to handle differently. Reaching for Box<dyn Error> erases that distinction; the caller can only print it, not match on which thing went wrong. The idiomatic fix is your own error type: an enum with one variant per failure mode, wired up so ? can convert into it automatically.

The idea, slowly

One enum, one variant per way to fail

Think of the error enum as an honest list of everything that can go wrong, named the way you’d explain it to a teammate:

#[derive(Debug)]
enum ConfigError {
    Missing(String),                    // a key wasn't set
    Invalid(std::num::ParseIntError),   // a key was set, but not a valid number
}

fn main() {
    let e = ConfigError::Missing("PORT".to_string());
    println!("{:?}", e);
}

This is just a normal enum — nothing Rust-specific about error types yet. #[derive(Debug)] gives you a {:?} representation for free, which every error type should have.

Telling the story: impl Display

Debug is for programmers; Display is for humans. Implementing std::fmt::Display gives your error a {} representation — the message a user or a log line would actually show:

use std::fmt;

#[derive(Debug)]
enum ConfigError {
    Missing(String),
    Invalid(std::num::ParseIntError),
}

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::Invalid(e) => write!(f, "invalid value: {e}"),
        }
    }
}

fn main() {
    let e = ConfigError::Missing("PORT".to_string());
    println!("{}", e); // missing key: PORT
}

The match inside fmt is exhaustive, same as any other match — add a variant later and the compiler will point at every Display (and every other match) that now needs updating.

Becoming a “real” error: impl std::error::Error

Display alone makes a type printable, but Rust’s error-handling ecosystem (the ? operator’s conversions, Box<dyn Error>, logging libraries) is built around the std::error::Error trait. Implementing it — often with an empty body, since it has sensible defaults — is what makes your type interoperate:

use std::fmt;

#[derive(Debug)]
enum ConfigError {
    Missing(String),
    Invalid(std::num::ParseIntError),
}

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::Invalid(e) => write!(f, "invalid value: {e}"),
        }
    }
}

impl std::error::Error for ConfigError {}

fn main() {
    let e: Box<dyn std::error::Error> = Box::new(ConfigError::Missing("PORT".to_string()));
    println!("{e}");
}

What the compiler is thinking: std::error::Error requires Debug + Display as supertraits — that’s why both had to come first. Once all three are in place, ConfigError can be boxed into Box<dyn Error>, returned alongside other error types, and passed to anything generic over E: std::error::Error.

The magic: impl From<X> for MyError lets ? convert automatically

Recall from the last lesson: ? calls From::from on the error it sees, converting it into the function’s declared error type. Implement From<ParseIntError> for ConfigError once, and every ? on a .parse() call inside a function returning Result<_, ConfigError> converts automatically — no .map_err(...) needed:

use std::fmt;

#[derive(Debug)]
enum ConfigError {
    Missing(String),
    Invalid(std::num::ParseIntError),
}

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::Invalid(e) => write!(f, "invalid value: {e}"),
        }
    }
}
impl std::error::Error for ConfigError {}

impl From<std::num::ParseIntError> for ConfigError {
    fn from(e: std::num::ParseIntError) -> Self {
        ConfigError::Invalid(e)
    }
}

fn get_port(value: Option<&str>) -> Result<u16, ConfigError> {
    let text = value.ok_or_else(|| ConfigError::Missing("PORT".to_string()))?;
    let port: u16 = text.parse()?; // ParseIntError -> ConfigError via From, automatically
    Ok(port)
}

fn main() {
    println!("{:?}", get_port(Some("8080"))); // Ok(8080)
    println!("{:?}", get_port(Some("nope")));  // Err(Invalid(ParseIntError { .. }))
    println!("{:?}", get_port(None));          // Err(Missing("PORT"))
}

Now the caller gets a single, specific error type they can actually match on — ConfigError::Missing vs ConfigError::Invalid — instead of an opaque Box<dyn Error> that can only be printed.

Chaining causes: .source()

Sometimes an error wraps another error, and callers (or logging tools) want to walk the whole chain — “this failed, because that failed, because this other thing failed.” The Error trait has a source() method, defaulted to None, that you can override to expose the wrapped error:

use std::fmt;

#[derive(Debug)]
enum ConfigError {
    Invalid(std::num::ParseIntError),
}

impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "invalid config value")
    }
}

impl std::error::Error for ConfigError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            ConfigError::Invalid(e) => Some(e), // the underlying ParseIntError
        }
    }
}

fn main() {
    let e = ConfigError::Invalid("nope".parse::<i32>().unwrap_err());
    println!("{e}");
    if let Some(cause) = std::error::Error::source(&e) {
        println!("caused by: {cause}");
    }
}

You won’t need source() for every error type, but it’s what lets tools print a full “Caused by: … Caused by: …” chain instead of just the top-level message.

Common mistakes

  • Forgetting impl std::error::Error. Display alone lets you print the error, but without Error, your type won’t compose with code expecting Box<dyn Error>, and ? can’t convert into other error types that rely on the blanket Error conversions.
  • Skipping From impls and using .map_err(|e| ConfigError::Invalid(e)) everywhere instead. It works, but it’s exactly the repetition From + ? exists to eliminate — implement From once per source error type and let ? do the wrapping.
  • One giant catch-all variant instead of one per failure mode. ConfigError::Other(String) for everything defeats the point — callers can no longer match on which thing failed. Give each real failure mode its own variant.
  • Writing all of this by hand for every project. It’s exactly what the thiserror crate automates — see the next lesson before hand-rolling a large error enum from scratch.

More examples

Three ways a CSV row can go wrong

A small import tool has to reject bad rows without crashing the whole batch — an enum with one variant per failure mode lets it explain exactly what was wrong with each line.

#[derive(Debug)]
enum RowError {
    Empty,
    WrongFieldCount(usize),
    BadNumber(String),
}

fn parse_row(line: &str) -> Result<(String, f64), RowError> {
    if line.trim().is_empty() {
        return Err(RowError::Empty);
    }
    let fields: Vec<&str> = line.split(',').collect();
    if fields.len() != 2 {
        return Err(RowError::WrongFieldCount(fields.len()));
    }
    let price: f64 = fields[1]
        .trim()
        .parse()
        .map_err(|_| RowError::BadNumber(fields[1].to_string()))?;
    Ok((fields[0].to_string(), price))
}

fn main() {
    for line in ["apple,1.50", "banana", "  ", "pear,free"] {
        println!("{line:?} -> {:?}", parse_row(line));
    }
}

Converting a std error automatically with From

Loading a settings file can fail because the file isn’t there (an io::Error) or because it’s empty — wiring up From<io::Error> means ? handles the first case without a .map_err(...) at the call site.

use std::fmt;
use std::io;

#[derive(Debug)]
enum SettingsError {
    Read(io::Error),
    Empty,
}

impl fmt::Display for SettingsError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            SettingsError::Read(e) => write!(f, "couldn't read settings file: {e}"),
            SettingsError::Empty => write!(f, "settings file is empty"),
        }
    }
}

impl From<io::Error> for SettingsError {
    fn from(e: io::Error) -> Self {
        SettingsError::Read(e)
    }
}

fn load_settings(path: &str) -> Result<String, SettingsError> {
    let text = std::fs::read_to_string(path)?; // io::Error -> SettingsError via From
    if text.trim().is_empty() {
        return Err(SettingsError::Empty);
    }
    Ok(text)
}

fn main() {
    match load_settings("does-not-exist.toml") {
        Ok(text) => println!("loaded: {text}"),
        Err(e) => println!("error: {e}"),
    }
}

Reacting differently depending on which variant you got

A network call might be worth retrying, or might not — matching on the specific error variant lets the caller decide, instead of treating every failure the same way.

#[derive(Debug)]
enum FetchError {
    Timeout,
    NotFound,
}

fn fetch(attempt: u32) -> Result<String, FetchError> {
    match attempt {
        0 => Err(FetchError::Timeout),
        1 => Err(FetchError::Timeout),
        _ => Ok("payload".to_string()),
    }
}

fn main() {
    let mut attempt = 0;
    loop {
        match fetch(attempt) {
            Ok(data) => {
                println!("got it: {data}");
                break;
            }
            Err(FetchError::Timeout) => {
                println!("timed out, retrying...");
                attempt += 1;
            }
            Err(FetchError::NotFound) => {
                println!("gone for good, giving up");
                break;
            }
        }
    }
}

Walking a chain of causes with .source()

When a database wrapper fails because the underlying connection failed, exposing that inner error through .source() lets a logger print the full “here’s what actually broke” chain instead of a vague one-liner.

use std::error::Error;
use std::fmt;
use std::io;

#[derive(Debug)]
struct DbError {
    cause: io::Error,
}

impl fmt::Display for DbError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "could not connect to database")
    }
}

impl Error for DbError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        Some(&self.cause)
    }
}

fn connect() -> Result<(), DbError> {
    let cause = io::Error::new(io::ErrorKind::ConnectionRefused, "port 5432 refused");
    Err(DbError { cause })
}

fn main() {
    if let Err(e) = connect() {
        println!("error: {e}");
        let mut source = e.source();
        while let Some(s) = source {
            println!("  caused by: {s}");
            source = s.source();
        }
    }
}

Your turn

get_port should convert a ParseIntError into a ConfigError automatically via ?, but it doesn’t compile.

use std::fmt;

#[derive(Debug)]
enum ConfigError {
    Missing(String),
    Invalid(std::num::ParseIntError),
}

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::Invalid(e) => write!(f, "invalid value: {e}"),
        }
    }
}
impl std::error::Error for ConfigError {}

fn get_port(value: Option<&str>) -> Result<u16, ConfigError> {
    let text = value.ok_or_else(|| ConfigError::Missing("PORT".to_string()))?;
    let port: u16 = text.parse()?; // no From<ParseIntError> for ConfigError yet
    Ok(port)
}

fn main() {
    println!("{:?}", get_port(Some("nope")));
}
Show solution

? needs a From<ParseIntError> for ConfigError impl to convert the error from .parse() into ConfigError. Without it, the compiler reports a type mismatch on the ? line — it has a ParseIntError and nowhere to convert it. Add the From impl:

use std::fmt;

#[derive(Debug)]
enum ConfigError {
    Missing(String),
    Invalid(std::num::ParseIntError),
}

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::Invalid(e) => write!(f, "invalid value: {e}"),
        }
    }
}
impl std::error::Error for ConfigError {}

impl From<std::num::ParseIntError> for ConfigError {
    fn from(e: std::num::ParseIntError) -> Self {
        ConfigError::Invalid(e)
    }
}

fn get_port(value: Option<&str>) -> Result<u16, ConfigError> {
    let text = value.ok_or_else(|| ConfigError::Missing("PORT".to_string()))?;
    let port: u16 = text.parse()?; // now converts automatically
    Ok(port)
}

fn main() {
    println!("{:?}", get_port(Some("nope"))); // Err(Invalid(ParseIntError { .. }))
    println!("{:?}", get_port(Some("8080"))); // Ok(8080)
}

Once From<ParseIntError> for ConfigError exists, ? finds it and wraps the error automatically — that’s the whole point of the pattern: implement the conversion once, and every fallible call site in a function returning Result<_, ConfigError> gets it for free.

Quick check

Remember this

  • One enum variant per distinct failure mode keeps the caller’s match meaningful — resist collapsing everything into one catch-all variant.
  • Implement Display (a human message) + std::error::Error (interoperability); Error requires Debug + Display as supertraits.
  • impl From<X> for MyError for each source error type lets ? auto-convert — implement it once, use ? everywhere instead of .map_err(...) at every call site.
  • The source() method on Error (default None) lets callers walk the underlying cause chain when your error wraps another one.
  • This whole pattern is boilerplate-heavy by hand — the next lesson shows how thiserror generates most of it for you.

Go deeper

Next:

thiserror and anyhow

Intermediate · Abstractions

What & why

The ConfigError from the last lesson took five separate pieces — the enum, impl Display, impl Error, one impl From per source error — just to model two ways of failing. Multiply that by every error type in a real project and it’s a lot of near-identical boilerplate. Almost every real Rust codebase reaches for one of two crates to cut it down: thiserror derives all that boilerplate for a precise, match-able error enum (great for libraries), and anyhow gives you a single catch-all error type for application code that just wants to propagate failures upward with a helpful message attached (great for binaries).

The idea, slowly

The problem, restated

Here’s the previous lesson’s ConfigError, in full, as a reminder of what we’re about to compress:

#![allow(unused)]
fn main() {
use std::fmt;

#[derive(Debug)]
enum ConfigError {
    Missing(String),
    Invalid(std::num::ParseIntError),
}

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::Invalid(e) => write!(f, "invalid value: {e}"),
        }
    }
}
impl std::error::Error for ConfigError {}
impl From<std::num::ParseIntError> for ConfigError {
    fn from(e: std::num::ParseIntError) -> Self {
        ConfigError::Invalid(e)
    }
}
}

Four trait impls to express two variants. thiserror generates all of it from attributes on the enum itself.

thiserror: derive the boilerplate

Add thiserror = "1" (or "2", check the latest) to Cargo.toml, then #[derive(thiserror::Error)] with an #[error("...")] message per variant:

#![allow(unused)]
fn main() {
#[derive(thiserror::Error, Debug)]
enum ConfigError {
    #[error("missing key: {0}")]
    Missing(String),

    #[error("invalid value")]
    Invalid(#[from] std::num::ParseIntError),
}
}

That’s the entire type. #[derive(Error)] generates the std::error::Error impl; each #[error("...")] generates the matching arm of Display ({0} refers to the variant’s first field, same idea as format!); and #[from] on a field generates the From<ParseIntError> for ConfigError impl too, so ? still auto-converts exactly as before. Four hand-written impls become two attributes.

What the compiler is thinking: #[derive(thiserror::Error)] is a proc macro — it runs at compile time and writes ordinary impl Display / impl Error / impl From blocks for you, identical in spirit to what you wrote by hand last lesson. #[from] can only appear on one field per underlying error type, because it’s generating a From impl, and a type can only have one From<ParseIntError> implementation.

anyhow: one error type for applications

thiserror is precise — it’s for library code whose callers want to match on exactly what went wrong. But main and application-level code usually don’t match on error variants; they just want to propagate something that failed, print it, and exit. Add anyhow = "1" and reach for anyhow::Result<T> — shorthand for Result<T, anyhow::Error> — which any error type converts into via ?:

fn parse_port(text: &str) -> anyhow::Result<u16> {
    let port: u16 = text.parse()?; // ParseIntError converts into anyhow::Error automatically
    Ok(port)
}

fn main() -> anyhow::Result<()> {
    let port = parse_port("8080")?;
    println!("listening on {port}");
    Ok(())
}

No enum, no Display impl, no From impl — anyhow::Error accepts anything implementing std::error::Error (or a plain string), so every ? in the function just works. This is the same idea as Box<dyn Error> from two lessons ago, but with a much nicer API layered on top — including the context features below.

Adding context with anyhow::Context

A bare ParseIntError bubbling up from deep in your code says “invalid digit found in string” — technically true, unhelpful in a log. anyhow::Context (a trait — bring it into scope with use anyhow::Context;) adds .context("...") to any Result, attaching a message without losing the original error:

use anyhow::{Context, Result};

fn parse_port(text: &str) -> Result<u16> {
    text.parse()
        .context("PORT must be a valid port number")
}

fn main() -> Result<()> {
    let port = parse_port("not-a-number")?;
    println!("listening on {port}");
    Ok(())
}

If this fails, printing the error (e.g. via eprintln!("{e:#}") or letting main return the error) shows both layers: PORT must be a valid port number: invalid digit found in string. .with_context(|| ...) is the lazy version — for when building the message string costs something and you only want to pay for it on the failure path, same trade-off as .unwrap_or_else() from the first lesson.

bail! and ensure!: early returns without a match

For application code, constructing a one-off error just to return Err(...) is more ceremony than the situation deserves. anyhow::bail! builds an error from a format string and returns immediately; anyhow::ensure! is bail! behind a condition check — like a fallible assert!:

#![allow(unused)]
fn main() {
use anyhow::{bail, ensure, Result};

fn set_port(port: i32) -> Result<u16> {
    if port < 0 {
        bail!("port cannot be negative: {port}");
    }
    ensure!(port <= 65535, "port {port} is out of range");
    Ok(port as u16)
}
}

ensure!(cond, "message") is exactly equivalent to if !cond { bail!("message") } — reach for whichever reads more clearly at the call site.

Rule of thumb: thiserror for libraries, anyhow for binaries

  • Library crate (code other crates will depend on): use thiserror. Callers may need to match on which error happened to decide how to react — anyhow::Error erases the concrete type, so a caller can’t match on it at all, only print or downcast it.
  • Binary / application code (the thing that’s actually run): use anyhow. Nobody downstream needs to match on your main’s errors; they need a good message and a nonzero exit code.

It’s common to see both in the same project: a library crate exposes a thiserror enum, and the application crate that depends on it uses anyhow::Result everywhere, letting ? convert the library’s precise errors in via anyhow::Error’s blanket From impl.

Common mistakes

  • Using anyhow in a library’s public API. It forces every downstream caller into anyhow too, and they lose the ability to match on specific failures. Keep anyhow at the application boundary; expose a thiserror enum (or a plain Result<T, YourError>) from a library.
  • Calling .context(...) without use anyhow::Context; in scope. context isn’t an inherent method on Result — it’s a trait method, so it doesn’t exist until the trait is imported.
  • Putting #[from] on two fields of the same source error type. thiserror generates one From<X> impl per #[from] field; two fields of the same X would need two conflicting From<X> impls, which doesn’t compile.
  • Expecting to match on an anyhow::Error. It’s intentionally type-erased. If you need to distinguish error cases in application code, either keep using a thiserror enum there too, or .downcast_ref::<SpecificError>() on the anyhow::Error (rare, and usually a sign thiserror was the better fit).

More examples

A thiserror enum with two different #[from] sources

A config loader can fail while reading the file (io::Error) or while parsing a value out of it (ParseIntError) — #[from] on each field generates the matching From impl, so ? still converts both automatically.

#![allow(unused)]
fn main() {
use std::io;
use std::num::ParseIntError;

#[derive(thiserror::Error, Debug)]
enum ConfigLoadError {
    #[error("couldn't read config file")]
    Io(#[from] io::Error),

    #[error("config value isn't a valid number")]
    BadNumber(#[from] ParseIntError),
}

fn load_max_connections(path: &str) -> Result<u32, ConfigLoadError> {
    let text = std::fs::read_to_string(path)?; // io::Error -> ConfigLoadError
    let max: u32 = text.trim().parse()?;       // ParseIntError -> ConfigLoadError
    Ok(max)
}
}

Attaching context at every step of a pipeline

main here reads a file, parses it, and normalizes the result — .context(...) at each fallible step means a failure says exactly which step broke, not just what the underlying error was.

use anyhow::{Context, Result};

fn run() -> Result<()> {
    let raw = std::fs::read_to_string("threshold.txt")
        .context("reading threshold.txt")?;
    let threshold: f64 = raw
        .trim()
        .parse()
        .context("threshold.txt must contain a number")?;
    let normalized = (threshold / 100.0).clamp(0.0, 1.0);
    println!("normalized threshold: {normalized}");
    Ok(())
}

fn main() -> Result<()> {
    run().context("startup failed")
}

Bailing out before doing any real work

A discount percentage outside 0-100 is nonsense input — bail! rejects it in one line, before the function bothers computing anything with it.

use anyhow::{bail, Result};

fn apply_discount(price: f64, percent: f64) -> Result<f64> {
    if !(0.0..=100.0).contains(&percent) {
        bail!("discount percent must be between 0 and 100, got {percent}");
    }
    Ok(price * (1.0 - percent / 100.0))
}

fn main() -> Result<()> {
    println!("{:.2}", apply_discount(80.0, 25.0)?);
    println!("{:.2}", apply_discount(80.0, 150.0)?); // bails before any math happens
    Ok(())
}

A library’s precise errors, wrapped in anyhow at the application boundary

The library crate below exposes a thiserror enum so its callers could match on specific failures; the binary that uses it doesn’t care and just wants ? to work, so it returns anyhow::Result instead.

// --- lib.rs (a library crate) ---
#[derive(thiserror::Error, Debug)]
pub enum StorageError {
    #[error("key not found: {0}")]
    NotFound(String),
}

pub fn get(key: &str) -> Result<String, StorageError> {
    if key == "config" {
        Ok("value".to_string())
    } else {
        Err(StorageError::NotFound(key.to_string()))
    }
}

// --- main.rs (the binary crate, depends on the library above) ---
fn main() -> anyhow::Result<()> {
    let value = get("missing-key")?; // StorageError -> anyhow::Error automatically
    println!("{value}");
    Ok(())
}

Your turn

read_port is meant to attach a helpful message to a parse failure, but it doesn’t compile.

use anyhow::Result;

fn read_port(text: &str) -> Result<u16> {
    let port: u16 = text
        .parse()
        .context("PORT must be a valid port number")?; // error: no method `context` found
    Ok(port)
}

fn main() -> Result<()> {
    println!("{}", read_port("nope")?);
    Ok(())
}
Show solution

.context(...) comes from the anyhow::Context trait, not from an inherent method on Result. Without use anyhow::Context;, the compiler can’t find the method at all: no method named 'context' found for enum 'Result' in the current scope. Import the trait:

use anyhow::{Context, Result};

fn read_port(text: &str) -> Result<u16> {
    let port: u16 = text
        .parse()
        .context("PORT must be a valid port number")?;
    Ok(port)
}

fn main() -> Result<()> {
    println!("{}", read_port("nope")?);
    Ok(())
}

Now text.parse() (which fails with ParseIntError) gets wrapped in an anyhow::Error carrying the extra context message, and ? propagates that combined error out of read_port. Running this prints something like Error: PORT must be a valid port number: invalid digit found in string — the original cause is still there, just with a human message attached in front of it.

Quick check

Remember this

  • thiserror: #[derive(Error)] plus #[error("...")] per variant generates Display and std::error::Error; #[from] on a field generates the matching From impl, so ? still auto-converts.
  • anyhow::Result<T> = Result<T, anyhow::Error> — any error type implementing std::error::Error converts into it via ?, making it the fast default for main and application code.
  • .context("...") / .with_context(|| ...) (from the anyhow::Context trait — remember to import it) attach a human message to a failing Result without discarding the original error.
  • anyhow::bail!("...") returns early with a formatted error; anyhow::ensure!(cond, "...") is a fallible assert! — bails if the condition is false.
  • Rule of thumb: thiserror for libraries (callers need to match on specific variants), anyhow for binaries/applications (callers just want to log or exit).

Go deeper

Next:

The builder pattern

Intermediate · Abstractions

What & why

Many languages let you write new Server(host: "localhost", port: 8080, timeout: None) — named arguments, some optional, in any order. Rust has neither constructor overloading nor named/optional function arguments. A fn new(host: String, port: u16, timeout_ms: Option<u32>, retries: u8, tls: bool) gets unreadable fast, and every call site is a wall of positional values you have to count to understand. The builder pattern is Rust’s answer: a separate type with small chainable setter methods and a final .build() that assembles (and validates) the real struct. It’s not a hack — it’s the idiomatic, ecosystem-standard way to construct anything with several optional pieces (reqwest::Client::builder(), std::process::Command, std::thread::Builder).

The idea, slowly

Why not just add fields as constructor arguments?

Imagine a ServerConfig with a required host, and optional port, timeout_ms, and tls. Without a builder you’re stuck picking one bad option:

  • One giant constructor with every field as a parameter — callers must remember the order, and ServerConfig::new("localhost", 8080, 5000, true) reads like noise; nobody can tell what 5000 or true mean at a glance.
  • A pile of near-duplicate constructors (new, new_with_port, new_with_port_and_tls, …) — this is “constructor overloading” simulated by hand, and it explodes combinatorially as fields grow.

A builder sidesteps both: each optional piece gets its own named method, called only when you need it, in whatever order you like.

The chainable setter pattern

The trick is that every setter takes self by value and returns Self. Taking self (not &self) means the method consumes the builder and hands back a (modified) one, which is exactly what lets you chain .method().method().method() — each call produces the next builder in the chain:

#[derive(Default)]
struct ServerConfigBuilder {
    host: String,
    port: u16,
    tls: bool,
}

impl ServerConfigBuilder {
    fn host(mut self, host: &str) -> Self {
        self.host = host.to_string();
        self // hand the (modified) builder back
    }

    fn port(mut self, port: u16) -> Self {
        self.port = port;
        self
    }

    fn tls(mut self, tls: bool) -> Self {
        self.tls = tls;
        self
    }
}

fn main() {
    let builder = ServerConfigBuilder::default()
        .host("localhost")
        .port(8080)
        .tls(true);

    println!("host={} port={} tls={}", builder.host, builder.port, builder.tls);
}

What the compiler is thinking: mut self means the method owns the builder for the duration of its body — it’s free to mutate its own copy. Returning self at the end moves that (now-updated) builder out to the caller. Because the return type is Self, the very next .method() call has something of the right type to call on. Drop the -> Self and forget to return self, and the method implicitly returns () — the chain breaks at the next call with a type error, not at the method you actually got wrong (more on this below).

#[derive(Default)] gives every field its zero value (String::new(), 0, false) for free, so ServerConfigBuilder::default() is a clean starting point without writing a new() by hand.

.build(): where validation lives

So far the builder is just a struct in disguise. The real value shows up when some fields are required and others aren’t. Store required fields as Option<T> inside the builder, and let .build() check that they were actually set — returning a Result so the caller can’t ignore a missing field:

struct ServerConfig {
    host: String,
    port: u16,
}

#[derive(Default)]
struct ServerConfigBuilder {
    host: Option<String>,
    port: Option<u16>,
}

impl ServerConfigBuilder {
    fn host(mut self, host: &str) -> Self {
        self.host = Some(host.to_string());
        self
    }

    fn port(mut self, port: u16) -> Self {
        self.port = Some(port);
        self
    }

    fn build(self) -> Result<ServerConfig, String> {
        Ok(ServerConfig {
            host: self.host.ok_or("host is required")?,
            port: self.port.unwrap_or(8080), // has a sensible default
        })
    }
}

fn main() {
    let ok = ServerConfigBuilder::default().host("localhost").build();
    let missing = ServerConfigBuilder::default().port(9000).build();

    match ok {
        Ok(c) => println!("ok: host={} port={}", c.host, c.port),
        Err(e) => println!("error: {}", e),
    }
    match missing {
        Ok(c) => println!("ok: host={} port={}", c.host, c.port),
        Err(e) => println!("error: {}", e), // this one runs: "host is required"
    }
}

.build() takes self by value one last time (no more chaining after this — construction is finished), uses ? and .ok_or(...) to turn a missing Option into an Err, and only returns Ok(ServerConfig { .. }) once every required piece is actually present. This is the same Result/? discipline from error handling, applied to object construction instead of a fallible computation.

When a builder is overkill

A builder is worth its ceremony when a struct has several optional fields, or when construction needs validation. For two or three fields with no real optionality, it’s pure overhead — reach for Default plus struct-update syntax instead:

#[derive(Default, Debug)]
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let origin = Point::default();
    let shifted = Point { x: 10, ..Default::default() }; // y stays 0
    println!("{:?} {:?}", origin, shifted);
}

..Default::default() fills in every field you didn’t name explicitly. No builder type, no .build(), no chain — just a struct literal. Save the builder for when it earns its keep.

Common mistakes

  • A builder for a 2-3 field struct. It’s more code to read and maintain than a plain constructor or Default + ..Default::default(). Builders pay off once fields are numerous, optional, or need validation — not by default.
  • Forgetting to return Self from a setter. If a setter’s signature is fn port(mut self, port: u16) (no -> Self), it implicitly returns (). The setter itself compiles fine — the error shows up one call later, at the next .method() in the chain, as “no method named port found for type ()”. Always double-check every setter ends in -> Self { ...; self }.
  • Skipping validation in .build(). If .build() just always returns Ok(...) (or isn’t fallible at all) for a struct with genuinely required fields, you’ve reinvented “trust me it’s fine” — exactly what Result exists to avoid. Use Option fields plus .ok_or(...)? for anything required.
  • Mutating through &mut self instead of consuming self. Both styles exist in real code, but mixing them confuses callers: consuming-self builders must be reassigned (b = b.port(80)) or chained directly; &mut self builders mutate in place and return &mut Self. Pick one style per builder and stay consistent.

More examples

An HTTP-request-style builder

Building an HTTP request has a handful of optional pieces — headers especially, since there can be any number of them — so each piece gets its own chainable method instead of a constructor with a Vec parameter.

#[derive(Debug, Default)]
struct HttpRequest {
    method: String,
    url: String,
    headers: Vec<(String, String)>,
}

#[derive(Default)]
struct RequestBuilder {
    method: String,
    url: String,
    headers: Vec<(String, String)>,
}

impl RequestBuilder {
    fn method(mut self, method: &str) -> Self {
        self.method = method.to_string();
        self
    }

    fn url(mut self, url: &str) -> Self {
        self.url = url.to_string();
        self
    }

    fn header(mut self, key: &str, value: &str) -> Self {
        self.headers.push((key.to_string(), value.to_string()));
        self
    }

    fn build(self) -> HttpRequest {
        HttpRequest { method: self.method, url: self.url, headers: self.headers }
    }
}

fn main() {
    let req = RequestBuilder::default()
        .method("GET")
        .url("https://api.example.com/users")
        .header("Authorization", "Bearer token123")
        .header("Accept", "application/json")
        .build();

    println!("{} {} ({} headers)", req.method, req.url, req.headers.len());
}

A required field enforced at .build()

An email with no recipient isn’t really an email — storing to as Option<String> and checking it in .build() makes “forgot to set the recipient” a Result::Err instead of a silently blank field.

struct Email {
    to: String,
    subject: String,
}

#[derive(Default)]
struct EmailBuilder {
    to: Option<String>,
    subject: Option<String>,
}

impl EmailBuilder {
    fn to(mut self, addr: &str) -> Self {
        self.to = Some(addr.to_string());
        self
    }

    fn subject(mut self, subject: &str) -> Self {
        self.subject = Some(subject.to_string());
        self
    }

    fn build(self) -> Result<Email, String> {
        Ok(Email {
            to: self.to.ok_or("an email needs a recipient")?,
            subject: self.subject.unwrap_or_else(|| "(no subject)".to_string()),
        })
    }
}

fn main() {
    let missing_recipient = EmailBuilder::default().subject("Hi!").build();
    match missing_recipient {
        Ok(e) => println!("sent to {}", e.to),
        Err(e) => println!("refused to send: {e}"),
    }
}

Default plus overriding just the fields that matter

Most calls only need to tweak one or two settings out of many — start from Default::default() and chain only the setters you actually care about, leaving everything else at its sensible default.

#[derive(Debug, Default, Clone)]
struct RequestOptions {
    timeout_secs: u32,
    retries: u8,
    follow_redirects: bool,
}

impl RequestOptions {
    fn timeout_secs(mut self, secs: u32) -> Self {
        self.timeout_secs = secs;
        self
    }

    fn retries(mut self, retries: u8) -> Self {
        self.retries = retries;
        self
    }
}

fn main() {
    // Most defaults are fine; only override the two that matter for this call.
    let opts = RequestOptions::default().timeout_secs(30).retries(5);
    println!("{opts:?}"); // follow_redirects stays false, the Default value
}

Builder chain vs. the equivalent constructor call

Same Connection, built two ways — the positional constructor forces the reader to remember what each value means; the builder labels every value with the method that set it.

struct Connection {
    host: String,
    port: u16,
    timeout_ms: u32,
    tls: bool,
}

// The verbose way: one constructor, every field a positional argument.
fn new_connection(host: &str, port: u16, timeout_ms: u32, tls: bool) -> Connection {
    Connection { host: host.to_string(), port, timeout_ms, tls }
}

#[derive(Default)]
struct ConnectionBuilder {
    host: String,
    port: u16,
    timeout_ms: u32,
    tls: bool,
}

impl ConnectionBuilder {
    fn host(mut self, host: &str) -> Self { self.host = host.to_string(); self }
    fn port(mut self, port: u16) -> Self { self.port = port; self }
    fn tls(mut self, tls: bool) -> Self { self.tls = tls; self }
    fn build(self) -> Connection {
        Connection { host: self.host, port: self.port, timeout_ms: self.timeout_ms, tls: self.tls }
    }
}

fn main() {
    // Verbose: what does `5000` mean here without checking the signature?
    let a = new_connection("db.internal", 5432, 5000, true);

    // Builder: every value is labeled by the method name that set it.
    let b = ConnectionBuilder::default().host("db.internal").port(5432).tls(true).build();

    println!("{}:{} tls={}", a.host, a.port, a.tls);
    println!("{}:{} tls={}", b.host, b.port, b.tls);
}

Your turn

This builder for a ServerConfig doesn’t compile. One setter is missing something the rest of the chain depends on:

struct ServerConfig {
    host: String,
    port: u16,
}

#[derive(Default)]
struct ServerConfigBuilder {
    host: Option<String>,
    port: Option<u16>,
}

impl ServerConfigBuilder {
    fn host(mut self, host: &str) -> Self {
        self.host = Some(host.to_string());
        self
    }

    fn port(mut self, port: u16) {
        self.port = Some(port);
    }

    fn build(self) -> Result<ServerConfig, String> {
        Ok(ServerConfig {
            host: self.host.ok_or("host is required")?,
            port: self.port.ok_or("port is required")?,
        })
    }
}

fn main() {
    let config = ServerConfigBuilder::default()
        .host("localhost")
        .port(8080)
        .build();

    match config {
        Ok(c) => println!("host={} port={}", c.host, c.port),
        Err(e) => println!("error: {}", e),
    }
}
Show solution

fn port(mut self, port: u16) has no return type, so it implicitly returns (). The chain is .host(...) (returns Self, fine) .port(8080) (returns ()) .build() (called on (), which has no build method) — the compiler reports the error at .build(), even though port is the actual culprit. Give port a -> Self and return self:

struct ServerConfig {
    host: String,
    port: u16,
}

#[derive(Default)]
struct ServerConfigBuilder {
    host: Option<String>,
    port: Option<u16>,
}

impl ServerConfigBuilder {
    fn host(mut self, host: &str) -> Self {
        self.host = Some(host.to_string());
        self
    }

    fn port(mut self, port: u16) -> Self {
        self.port = Some(port);
        self
    }

    fn build(self) -> Result<ServerConfig, String> {
        Ok(ServerConfig {
            host: self.host.ok_or("host is required")?,
            port: self.port.ok_or("port is required")?,
        })
    }
}

fn main() {
    let config = ServerConfigBuilder::default()
        .host("localhost")
        .port(8080)
        .build();

    match config {
        Ok(c) => println!("host={} port={}", c.host, c.port), // host=localhost port=8080
        Err(e) => println!("error: {}", e),
    }
}

Every setter in a chain must return Self — a single missing -> Self breaks every call that comes after it.

Quick check

Remember this

  • Each setter takes mut self (or &mut self) and returns Self, so calls chain: Builder::new().name("x").port(8080).build().
  • .build() is where required-field validation happens, often returning a Result.
  • Prefer Default + struct-update syntax (..Default::default()) for simpler cases before reaching for a full builder.
  • A missing -> Self on one setter surfaces as a confusing error on the next method call, not on the setter itself.

Go deeper

Next:

The newtype pattern

Intermediate · Abstractions

What & why

UserId and OrderId might both just be a u64 under the hood — but a u64 is a u64 to the compiler, so nothing stops you from accidentally calling charge(order_id, user_id) with the arguments swapped. The newtype pattern wraps an existing type in a one-field tuple struct — struct UserId(u64); — to create a genuinely distinct type at compile time, for free at runtime. It’s how Rust gets type-safe units (Meters vs Feet), prevents ID mix-ups, and even works around a rule that would otherwise block you from implementing a trait you don’t own on a type you don’t own.

The idea, slowly

A one-field wrapper is a brand new type

A tuple struct with a single field is just a label the compiler now enforces:

struct Meters(f64);
struct Feet(f64);

fn main() {
    let height = Meters(1.8);
    let track_length = Feet(400.0);

    // Both are "just f64" underneath, but they are NOT interchangeable:
    // let mixed: Meters = track_length; // compile error: expected Meters, found Feet

    println!("{} meters, {} feet", height.0, track_length.0);
}

At runtime Meters(1.8) is exactly one f64 in memory — the wrapper costs nothing (this is called a “zero-cost abstraction”). At compile time, though, Meters and Feet are unrelated types. Pass a Feet where a Meters is expected and you get a type error immediately, not a silently wrong distance calculation three functions later.

Preventing mixed-up IDs

This is the newtype pattern’s bread-and-butter use case. Two IDs backed by the same primitive type are easy to swap by accident — the newtype makes that swap a compile error instead of a runtime bug:

struct UserId(u64);
struct OrderId(u64);

fn charge(user: UserId, order: OrderId) {
    println!("charging user #{} for order #{}", user.0, order.0);
}

fn main() {
    // charge(OrderId(1), UserId(2)); // compile error: arguments in the wrong order
    charge(UserId(2), OrderId(1));    // correct order, and the compiler checked it
}

Without the wrapper, both parameters would just be u64, and swapping them compiles fine — it’s a bug that only shows up when the wrong user gets charged for the wrong order. With UserId and OrderId as distinct types, the compiler catches the swap at the call site, every time.

The orphan rule, and how wrapping sidesteps it

Rust has a rule (the “orphan rule”) that blocks you from implementing a trait for a type when you own neither the trait nor the type. This exists to prevent two different crates from both implementing the same foreign trait for the same foreign type in incompatible ways. Concretely: you can’t write impl std::fmt::Display for Vec<String> in your own crate, because you own neither Display (that’s std’s) nor Vec (also std’s).

Wrapping the foreign type in a newtype you define fixes this — now you own the type (the wrapper), even though the trait is still foreign:

use std::fmt;

struct Names(Vec<String>);

impl fmt::Display for Names {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "{}", self.0.join(", "))
    }
}

fn main() {
    let names = Names(vec!["Alice".to_string(), "Bob".to_string()]);
    println!("{}", names); // Alice, Bob
}

What the compiler is thinking: the orphan rule only cares about the type in the impl ... for TYPE slot. Names is a type defined right here in this crate, so impl fmt::Display for Names is allowed — even though Display itself comes from std and the data inside is still a Vec<String>.

Getting at the inner value

A newtype doesn’t automatically inherit the wrapped type’s methods — Meters doesn’t gain f64’s methods just by wrapping one. You reach in with .0 (tuple structs index their single field as .0):

struct Meters(f64);

fn main() {
    let d = Meters(42.0);
    let doubled = Meters(d.0 * 2.0); // reach in with .0, then rewrap
    println!("{}", doubled.0);
}

For a wrapper you want to feel more like the type it holds (e.g. calling String methods directly on a newtype around String), implement Deref so . auto-forwards to the inner value — or implement From/Into so converting between the wrapper and the inner type is a clean .into() instead of manual .0 plumbing everywhere.

Common mistakes

  • Overusing newtypes for every primitive. Wrapping every u64 and String in the codebase adds .0 noise everywhere and slows readers down. Reach for a newtype when mixing two values up would be a real bug (IDs, units, currencies) — not reflexively.
  • Expecting inherited methods. struct Meters(f64) does not gain f64::sqrt() or arithmetic operators automatically. You need .0 to get at the inner value, or explicit trait impls (Deref, Add, …) to forward behavior.
  • Forgetting the newtype has no Display/Debug by default. Printing a bare Meters(1.8) with {} fails to compile until you either #[derive(Debug)] (for {:?}) or implement Display yourself (for {}) — wrapping a printable type doesn’t make the wrapper printable.
  • Reaching for a newtype when the orphan rule isn’t actually the problem. If you own the type already, just impl Trait for YourType directly — no wrapper needed. Newtypes solve orphan-rule blocks and type confusion, not every design problem.

More examples

Preventing unit mix-ups in a function signature

A race-length calculator should only ever see meters — wrapping feet in their own type forces the conversion to happen explicitly, instead of silently dividing meters by feet.

struct Meters(f64);
struct Feet(f64);

impl Feet {
    fn to_meters(&self) -> Meters {
        Meters(self.0 * 0.3048)
    }
}

fn track_length_in_laps(track: Meters, lap_length: Meters) -> f64 {
    track.0 / lap_length.0
}

fn main() {
    let track = Meters(1600.0);
    let lap = Feet(400.0).to_meters(); // must convert explicitly, no silent mixing
    println!("{:.1} laps", track_length_in_laps(track, lap));
}

Working around the orphan rule for Display

Vec<String> isn’t yours and Display isn’t yours either, so impl Display for Vec<String> won’t compile — wrap the tags in a newtype you own, and the impl block is suddenly allowed.

use std::fmt;

struct Tags(Vec<String>);

impl fmt::Display for Tags {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        let hashtags: Vec<String> = self.0.iter().map(|t| format!("#{t}")).collect();
        write!(f, "{}", hashtags.join(" "))
    }
}

fn main() {
    let post_tags = Tags(vec!["rust".to_string(), "programming".to_string(), "learning".to_string()]);
    println!("{post_tags}"); // #rust #programming #learning
}

Validating on the way in

An Email newtype whose only constructor checks the format means every Email value that exists anywhere in the program has already been validated — there’s no separate “unchecked string” path to forget to call.

struct Email(String);

impl Email {
    fn new(raw: &str) -> Result<Email, String> {
        if raw.contains('@') && raw.contains('.') {
            Ok(Email(raw.to_string()))
        } else {
            Err(format!("'{raw}' doesn't look like an email address"))
        }
    }
}

fn main() {
    for candidate in ["alice@example.com", "not-an-email"] {
        match Email::new(candidate) {
            Ok(e) => println!("valid: {}", e.0),
            Err(msg) => println!("invalid: {msg}"),
        }
    }
}

Letting the compiler catch a swapped argument

SkuId and WarehouseId are both u32 underneath, but as distinct types the compiler rejects a call that passes them in the wrong order — the bug is caught before the code ever runs.

struct SkuId(u32);
struct WarehouseId(u32);

fn restock(sku: SkuId, warehouse: WarehouseId, quantity: u32) {
    println!("adding {quantity} units of sku #{} to warehouse #{}", sku.0, warehouse.0);
}

fn main() {
    // restock(WarehouseId(7), SkuId(4021), 50); // compile error: arguments swapped
    restock(SkuId(4021), WarehouseId(7), 50);     // correct order, compiler-checked
}

Your turn

This function tries to print a Meters value directly. It doesn’t compile:

struct Meters(f64);

fn describe_distance(m: Meters) {
    println!("distance: {} meters", m);
}

fn main() {
    let d = Meters(42.0);
    describe_distance(d);
}
Show solution

The error is Meters doesn't implement std::fmt::Display (or {:?} isn’t implemented either, since there’s no derive(Debug)). Wrapping an f64 in Meters does not make Meters itself printable — the newtype is a brand new type with no methods or trait impls of its own by default. Reach into the wrapper with .0 to get the printable f64 back out:

struct Meters(f64);

fn describe_distance(m: Meters) {
    println!("distance: {} meters", m.0); // .0 gets the inner f64
}

fn main() {
    let d = Meters(42.0);
    describe_distance(d); // distance: 42 meters
}

(Alternatively, #[derive(Debug)] on Meters and printing with {:?} would also compile — but it prints Meters(42.0), not a clean 42. Implementing Display by hand gives full control over the format.)

Quick check

Remember this

  • struct Meters(f64); creates a type distinct from f64 at compile time, at zero cost at runtime.
  • A newtype prevents accidentally passing a UserId where an OrderId is expected, even though both are the same primitive underneath.
  • The orphan rule blocks impl ForeignTrait for ForeignType — wrapping ForeignType in a newtype you own sidesteps it, because you now own the type in the impl slot.
  • Access the inner value with .0, or implement Deref/From to make the wrapper more ergonomic to use.

Go deeper

Next:

Operator overloading

Intermediate · Abstractions

What & why

1 + 2 and p1 + p2 look like the same kind of thing, but for your own struct, + doesn’t mean anything until you tell the compiler what it means. In Rust, every operator is secretly a trait method — a + b is shorthand the compiler expands to Add::add(a, b). Implement the matching trait from std::ops (Add, Sub, Mul, Index, …) for your own type, and it gets to use +, -, *, [], and friends, exactly like a built-in number or collection would.

The idea, slowly

Operators desugar to trait methods

There’s no special compiler magic for + — it’s a lookup. When the compiler sees a + b, it looks for an Add implementation for a’s type and rewrites the expression as a call to it:

use std::ops::Add;

fn main() {
    let sum = 3.add(4); // exactly what 3 + 4 desugars to for integers
    println!("{}", sum);
    println!("{}", 3 + 4); // identical result, normal syntax
}

Integers implement Add in the standard library, which is why 3 + 4 works at all. Every arithmetic and indexing operator in Rust follows this same rule: a symbol is really a method call on a trait, dressed up in familiar syntax.

Implementing Add for your own type

To make + work on a struct, implement std::ops::Add for it. The trait has one associated type (Output, what the + expression evaluates to) and one method (add, what actually runs):

use std::ops::Add;

#[derive(Clone, Copy, Debug)]
struct Point { x: i32, y: i32 }

impl Add for Point {
    type Output = Point;
    fn add(self, rhs: Point) -> Point {
        Point { x: self.x + rhs.x, y: self.y + rhs.y }
    }
}

fn main() {
    let p1 = Point { x: 1, y: 2 };
    let p2 = Point { x: 3, y: 4 };
    let p3 = p1 + p2; // calls Add::add(p1, p2)
    println!("{:?}", p3); // Point { x: 4, y: 6 }
}

What the compiler is thinking: p1 + p2 is rewritten to Add::add(p1, p2) before type checking even happens. It then checks that Point implements Add, that add accepts a Point on the right-hand side, and that the result type matches how p3 is used. Sub and Mul work identically — impl Sub for Point { type Output = Point; fn sub(self, rhs: Point) -> Point { ... } } is all p1 - p2 needs.

Notice Point derives Copy. Most std::ops traits (including Add) take self by value, which moves (or copies, for Copy types) the left-hand operand. For a small struct like Point, deriving Copy means p1 + p2 doesn’t consume p1 — you can keep using it afterward. For a non-Copy type, self + rhs would move self, so you’d only be able to use the sum, not the original operands, unless you implement Add for references instead (impl Add for &Point).

Index and IndexMut: making container[key] work

The same idea extends to []. Implement Index (read) and IndexMut (read-write) to make your own type support subscript syntax:

use std::ops::Index;

struct Grid {
    cells: Vec<i32>,
    width: usize,
}

impl Index<(usize, usize)> for Grid {
    type Output = i32;
    fn index(&self, (row, col): (usize, usize)) -> &i32 {
        &self.cells[row * self.width + col]
    }
}

fn main() {
    let grid = Grid { cells: vec![1, 2, 3, 4, 5, 6], width: 3 };
    println!("{}", grid[(1, 2)]); // row 1, col 2 -> cells[1*3 + 2] = cells[5] = 6
}

grid[(1, 2)] desugars to *Index::index(&grid, (1, 2))index returns a reference, and the [] syntax automatically dereferences it for you. IndexMut follows the same shape but returns &mut Output, which is what lets grid[(1, 2)] = 9; work as an assignment.

When overloading helps — and when a named method is clearer

Operator overloading is a judgment call, not a default. It earns its place when the operator’s meaning is exactly what the reader would expect from ordinary math or collection syntax: Point + Point reads naturally as vector addition; Matrix * Matrix reads naturally as matrix multiplication; grid[(row, col)] reads naturally as indexing.

It goes wrong when + does something a reader wouldn’t guess from the symbol — merging two Config structs with “last one wins” semantics, or a + that has side effects like writing to a file. In those cases a named method (config.merged_with(other), log.append(entry)) is far clearer than a surprising operator, because the name tells the reader what actually happens instead of leaning on a symbol to imply it.

Common mistakes

  • Overloading an operator with surprising semantics. + that mutates one of its operands, has side effects, or doesn’t correspond to what “addition” would mean for your type is worse than a named method — readers bring assumptions to + that your code should honor, not violate.
  • Assuming Add gives you += for free. AddAssign (which powers +=) is a separate trait from Add. Implementing one does not implement the other — if you want both p1 + p2 and p += p2 to work, you implement both Add and AddAssign.
  • Forgetting most std::ops traits take self by value. If your type isn’t Copy, p1 + p2 moves p1 (and p2), so you can’t use them again afterward. Either derive Copy for small value-like types, or implement the operator for references (impl Add for &Point) so operands are borrowed instead of consumed.
  • Mismatched Output type. type Output = Point must match what add’s body actually returns and what call sites expect. A mismatch shows up as a type error at the + expression itself, which can look confusing if you don’t already know operators are trait calls.

More examples

Totaling a receipt in cents

A Money type that implements Add lets a checkout add up line items with plain +, instead of a .total() method that has to be remembered and called separately.

use std::ops::Add;

#[derive(Clone, Copy, Debug)]
struct Money { cents: u32 }

impl Add for Money {
    type Output = Money;
    fn add(self, rhs: Money) -> Money {
        Money { cents: self.cents + rhs.cents }
    }
}

fn main() {
    let coffee = Money { cents: 350 };
    let muffin = Money { cents: 275 };
    let total = coffee + muffin;
    println!("total: ${}.{:02}", total.cents / 100, total.cents % 100);
}

Applying a speed boost power-up

Mul<f64> lets a game scale a velocity by a plain number, so a power-up reads as speed * 2.5 instead of a helper function that rebuilds the struct by hand.

use std::ops::Mul;

#[derive(Clone, Copy, Debug)]
struct Velocity { dx: f64, dy: f64 }

impl Mul<f64> for Velocity {
    type Output = Velocity;
    fn mul(self, factor: f64) -> Velocity {
        Velocity { dx: self.dx * factor, dy: self.dy * factor }
    }
}

fn main() {
    let base_speed = Velocity { dx: 2.0, dy: 1.0 };
    let boosted = base_speed * 2.5; // speed boost power-up
    println!("{:?}", boosted);
}

A playlist that loops when it runs out of tracks

Index doesn’t have to mean “array position” — wrapping the index with % inside index() makes playlist[i] loop back to the start for any i, which is exactly how repeat playback behaves.

use std::ops::Index;

struct Playlist {
    tracks: Vec<String>,
}

impl Index<usize> for Playlist {
    type Output = String;
    fn index(&self, i: usize) -> &String {
        &self.tracks[i % self.tracks.len()] // wraps around for looping playback
    }
}

fn main() {
    let playlist = Playlist {
        tracks: vec!["Intro".to_string(), "Solo".to_string(), "Outro".to_string()],
    };

    for i in 0..5 {
        println!("track {}: {}", i, playlist[i]);
    }
}

Deducting a shipped order from warehouse stock

Sub makes “what’s left after this order ships” read the same way subtraction reads for ordinary numbers, instead of a .deduct(order) method.

use std::ops::Sub;

#[derive(Clone, Copy, Debug)]
struct Stock { units: u32 }

impl Sub for Stock {
    type Output = Stock;
    fn sub(self, rhs: Stock) -> Stock {
        Stock { units: self.units - rhs.units }
    }
}

fn main() {
    let warehouse = Stock { units: 120 };
    let shipped_order = Stock { units: 45 };
    let remaining = warehouse - shipped_order;
    println!("{:?}", remaining);
}

Your turn

Point implements Add, but this code also tries to use +=. It doesn’t compile:

use std::ops::Add;

#[derive(Clone, Copy, Debug)]
struct Point { x: i32, y: i32 }

impl Add for Point {
    type Output = Point;
    fn add(self, rhs: Point) -> Point {
        Point { x: self.x + rhs.x, y: self.y + rhs.y }
    }
}

fn main() {
    let mut p = Point { x: 1, y: 2 };
    p += Point { x: 3, y: 4 };
    println!("{:?}", p);
}
Show solution

The error is binary assignment operation += cannot be applied to type Point. Implementing Add only teaches the compiler what p1 + p2 means — += is a different operator backed by a different trait, AddAssign, which Point doesn’t implement yet. Add it:

use std::ops::{Add, AddAssign};

#[derive(Clone, Copy, Debug)]
struct Point { x: i32, y: i32 }

impl Add for Point {
    type Output = Point;
    fn add(self, rhs: Point) -> Point {
        Point { x: self.x + rhs.x, y: self.y + rhs.y }
    }
}

impl AddAssign for Point {
    fn add_assign(&mut self, rhs: Point) {
        self.x += rhs.x;
        self.y += rhs.y;
    }
}

fn main() {
    let mut p = Point { x: 1, y: 2 };
    p += Point { x: 3, y: 4 };
    println!("{:?}", p); // Point { x: 4, y: 6 }
}

Each operator symbol maps to its own trait — + to Add, += to AddAssign, - to Sub, -= to SubAssign, and so on. Implementing one never implies the other; you implement each operator you actually want to support.

Quick check

Remember this

  • Operators are trait methods in disguise: a + b desugars to Add::add(a, b).
  • impl Add for Point { type Output = Point; fn add(self, rhs: Point) -> Point { ... } } enables p1 + p2.
  • Index/IndexMut enable container[key] syntax for your own collection-like types.
  • + and += are separate traits (Add and AddAssign) — implementing one doesn’t give you the other.
  • Only implement an operator when its meaning is unambiguous — don’t overload + for something that isn’t really addition; use a named method instead.

Go deeper

Next:

Unit testing

Beginner · Runtime & ecosystem

What & why

A unit test is a small function that checks one piece of your code still behaves the way you expect — automatically, every time you run cargo test, instead of a human eyeballing output. Rust bakes the test runner right into Cargo: no framework to install, no config file to write. You tag a function #[test], put an assertion inside it, and Cargo finds it, runs it, and tells you pass or fail. This lesson goes deep on the actual toolkit: the assert! family, #[should_panic], why #[cfg(test)] matters, and the cargo test command itself.

The idea, slowly

The three pieces of every test

  1. The sticker #[test] — an attribute that tells Cargo “this function is a test, run it when testing.” A function without it is just a normal function; cargo test ignores it.
  2. An assertion — a line that says “this had better be true.” If it’s true, nothing happens. If it’s false, the test panics (crashes on purpose) and is marked failed.
  3. A name — pick a name that describes what’s being checked, like rejects_empty_username. When a test fails, Rust prints its name, so a good name is half the debugging done already.

assert!, assert_eq!, assert_ne! — the assertion family

These three macros are how a test actually fails. All three panic (and so fail the test) when their condition isn’t met:

fn main() {
    let a = 2 + 2;

    assert!(a > 0);        // fails if the condition is false
    assert_eq!(a, 4);      // fails if the two sides are NOT equal
    assert_ne!(a, 5);      // fails if the two sides ARE equal

    println!("all three checks passed");
}

Press Run — it prints the success message. Now change assert_eq!(a, 4) to assert_eq!(a, 5) and Run again: the program panics and prints something like assertion left == right failed, along with both values. That’s exactly the failure message cargo test would show you for a real test. Reach for assert_eq!/assert_ne! over plain assert!(a == b) whenever possible — on failure they print both sides, while assert! only tells you the condition was false.

#[cfg(test)] mod tests { use super::*; ... } — tests that vanish from your shipped binary

In a real project, tests live in the same file as the code they check, inside a module wrapped in #[cfg(test)]:

#![allow(unused)]
fn main() {
// This lives in the SAME file as your code — e.g. src/lib.rs.
// Run it with: cargo test

fn add(a: i32, b: i32) -> i32 {
    a + b
}

#[cfg(test)]              // "only compile this module when testing"
mod tests {
    use super::*;         // pull add() from the parent module into scope

    #[test]
    fn adds_two_numbers() {
        assert_eq!(add(2, 2), 4);
    }

    #[test]
    fn adds_negatives() {
        assert_eq!(add(-1, -1), -2);
    }
}
}

Two things happening here that beginners often skim past:

  • #[cfg(test)] is a compile-time switch, not a runtime one. It’s the compiler thinking: “Only build this module at all when the human runs cargo test.” In a normal cargo build or cargo build --release, this module isn’t just skipped — it isn’t compiled in the first place. Your shipped binary is exactly as small as if the tests didn’t exist. This also means a test module can freely use dev-dependencies (test-only crates listed under [dev-dependencies] in Cargo.toml) without those crates ever being linked into your real program.
  • use super::*; is what makes add visible. mod tests is a child module, so it doesn’t automatically see its parent’s items. super means “the module I’m nested in,” and use super::* imports everything from there — including private (non-pub) functions. This is the superpower unit tests have that integration tests (covered in the next lesson) don’t: they can reach into your private internals because they’re compiled as part of the same crate.

The Playground can’t run cargo test, but it can run a main that calls your function directly, so you can still sanity-check the logic:

fn add(a: i32, b: i32) -> i32 {
    a + b
}

fn main() {
    assert_eq!(add(2, 2), 4);
    assert_eq!(add(-1, -1), -2);
    println!("both checks passed");
}

#[should_panic] — when panicking IS the correct behavior

Sometimes the correct behavior of your code is to panic — indexing past the end of an array, for example. Tag the test #[should_panic] and it now passes only if the code inside panics:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    #[test]
    #[should_panic]
    fn reading_past_the_end_panics() {
        let numbers = [1, 2, 3];
        let _ = numbers[10]; // out of bounds — this panics, and that's the point
    }
}
}

Plain #[should_panic] passes on any panic, even one caused by an unrelated bug. Add expected = "..." to check the panic message actually contains the substring you meant:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    #[test]
    #[should_panic(expected = "index out of bounds")]
    fn panics_with_the_right_message() {
        let numbers = [1, 2, 3];
        let _ = numbers[10];
    }
}
}

Now the test only passes if the panic message contains "index out of bounds". If some other bug made the function panic with a different message, this version of the test correctly fails — the plain #[should_panic] version above would have passed by accident.

cargo test basics

In a real project (not the Playground), running:

cargo test

builds your code, runs every #[test] function it finds, and prints a summary like test result: ok. 2 passed; 0 failed. To run only the tests whose name contains a substring — handy once you have hundreds of tests — pass it as an argument:

cargo test adds

That runs adds_two_numbers and adds_negatives (both contain "adds") and skips everything else. The match is against the full test path (module path + function name), so cargo test tests::adds works too.

Common mistakes

  • Forgetting #[cfg(test)] on the test module. Without it, your test code compiles into every build, including release — and if that module uses a dev-dependency crate, your normal build can fail to compile, because dev-dependencies aren’t linked outside test/bench/example builds.
  • A test with no assertion. A #[test] function that never asserts anything always passes — it’s checking nothing. Every real test needs at least one assert!/assert_eq!/assert_ne!.
  • #[should_panic] without expected = "...". It passes on any panic, so a test can accidentally pass for the wrong reason — the code panicked, just not from the bug you meant to check for.
  • Reading too much into the assert_eq! panic message. It prints left and right, not actual and expected — Rust doesn’t know which side you intended as which. The convention is assert_eq!(actual, expected), but swapping the order still compiles and just flips which value shows as “left.”
  • Reusing state between tests. Tests run in parallel by default and in no guaranteed order. Two tests that share a file, an environment variable, or other global state can clobber each other and flake intermittently.

More examples

Validating a signup form’s username

A signup form’s username validator is a perfect candidate for tests — one function, several rules, and each rule deserves its own test so a failure points at exactly what broke.

#![allow(unused)]
fn main() {
fn is_valid_username(name: &str) -> bool {
    !name.is_empty() && name.len() <= 20 && name.chars().all(|c| c.is_alphanumeric() || c == '_')
}

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

    #[test]
    fn accepts_a_normal_username() {
        assert!(is_valid_username("ferris_the_crab"));
    }

    #[test]
    fn rejects_an_empty_username() {
        assert!(!is_valid_username(""));
    }

    #[test]
    fn rejects_spaces() {
        assert!(!is_valid_username("has space"));
    }
}
}

Calculating a shopping cart discount

A discount function has edge cases — no discount, a threshold just met, a threshold comfortably passed — and each one is a separate test instead of a mental note to check by hand.

#![allow(unused)]
fn main() {
fn discount_percent(cart_total: f64) -> f64 {
    if cart_total >= 100.0 {
        0.10
    } else if cart_total >= 50.0 {
        0.05
    } else {
        0.0
    }
}

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

    #[test]
    fn no_discount_below_fifty() {
        assert_eq!(discount_percent(20.0), 0.0);
    }

    #[test]
    fn five_percent_at_fifty() {
        assert_eq!(discount_percent(50.0), 0.05);
    }

    #[test]
    fn ten_percent_at_one_hundred() {
        assert_eq!(discount_percent(150.0), 0.10);
    }
}
}

Parsing a config line, testing the error path

A config parser needs to fail loudly on bad input, not silently return garbage — testing the Err case is just as important as testing the happy path.

#![allow(unused)]
fn main() {
fn parse_port(line: &str) -> Result<u16, String> {
    line.trim()
        .parse::<u16>()
        .map_err(|_| format!("invalid port: {line}"))
}

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

    #[test]
    fn parses_a_valid_port() {
        assert_eq!(parse_port("8080"), Ok(8080));
    }

    #[test]
    fn rejects_non_numeric_input() {
        assert!(parse_port("localhost").is_err());
    }
}
}

Proving a stack panics on underflow

A stack-based structure that’s documented to panic on underflow needs a test that proves it actually panics, not one that hopes it does.

#![allow(unused)]
fn main() {
struct FixedStack {
    items: Vec<i32>,
}

impl FixedStack {
    fn pop(&mut self) -> i32 {
        self.items.pop().expect("pop from an empty stack")
    }
}

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

    #[test]
    #[should_panic(expected = "pop from an empty stack")]
    fn popping_an_empty_stack_panics() {
        let mut stack = FixedStack { items: vec![] };
        stack.pop();
    }
}
}

Your turn

This test module has two bugs: it won’t compile, and even if it did, one test would fail for the wrong reason. Find both.

#![allow(unused)]
fn main() {
fn divide(a: i32, b: i32) -> i32 {
    a / b
}

#[cfg(test)]
mod tests {
    #[test]
    fn divides_evenly() {
        assert_eq!(divide(10, 2), 5);
    }

    #[test]
    #[should_panic(expected = "attempt to add with overflow")]
    fn dividing_by_zero_panics() {
        let _ = divide(10, 0);
    }
}
}
Show solution

Bug one: mod tests never brings divide into scope, so divide(10, 2) fails to compile with “cannot find function divide in this scope.” It needs use super::*;.

Bug two: dividing by zero panics with the message "attempt to divide by zero", not "attempt to add with overflow". With the wrong expected string, cargo test reports the test as failed even though the code panicked correctly — the message just didn’t match.

#![allow(unused)]
fn main() {
fn divide(a: i32, b: i32) -> i32 {
    a / b
}

#[cfg(test)]
mod tests {
    use super::*;   // needed to see `divide` from the parent module

    #[test]
    fn divides_evenly() {
        assert_eq!(divide(10, 2), 5);
    }

    #[test]
    #[should_panic(expected = "attempt to divide by zero")]
    fn dividing_by_zero_panics() {
        let _ = divide(10, 0);
    }
}
}

use super::* is easy to forget because the compiler error (“cannot find function”) looks like a typo, not a missing import. And #[should_panic(expected = "...")] is only useful if the string actually matches what the code panics with — when in doubt, panic it on purpose locally and copy the real message.

Quick check

Remember this

  • A test is a function tagged #[test]; run all of them with cargo test.
  • assert!, assert_eq!, and assert_ne! panic — and so fail the test — on a false condition, unequal values, or equal values respectively.
  • #[cfg(test)] mod tests { use super::*; ... } is the standard home for unit tests: use super::* gives access to private items, and #[cfg(test)] means the whole module is compiled only when testing, never in your shipped binary.
  • #[should_panic] passes on any panic; #[should_panic(expected = "...")] also checks the panic message, so you know you’re panicking for the right reason.
  • cargo test <substring> runs only the tests whose name contains that substring.

Go deeper

Next:

Integration testing

Intermediate · Runtime & ecosystem

What & why

Unit tests check pieces of your code from the inside, with full access to private internals. Integration tests check your crate the way an actual downstream user would: importing it like a dependency and calling only what’s pub. That catches a specific class of bug unit tests structurally can’t — “it works internally, but the public API I actually shipped doesn’t hang together.” Cargo has a dedicated tests/ directory for exactly this.

The idea, slowly

The tests/ directory: one file, one crate

Integration tests live in a top-level tests/ folder, as siblings of src/:

my_project/
├── Cargo.toml
├── src/
│   └── lib.rs        # your library code
└── tests/
    └── api.rs         # an integration test file

Cargo automatically compiles every .rs file directly under tests/ as its own separate binary crate, linked against your library the same way any external project would depend on it. That’s why the file imports your crate by name instead of using super:

#![allow(unused)]
fn main() {
// tests/api.rs
use my_crate::add;

#[test]
fn public_add_works() {
    assert_eq!(add(2, 2), 4);
}
}

There’s no #[cfg(test)] here, and no mod wrapper — the whole file only exists as test code by virtue of living in tests/, so Cargo already knows to build and run it only during cargo test.

Only pub items are visible

Because each tests/*.rs file is compiled as a genuinely separate crate, it goes through the same visibility rules as any other crate depending on yours: it can only see items marked pub. A private helper function inside src/lib.rs simply doesn’t exist as far as tests/api.rs is concerned — there’s no super to reach through, because it isn’t a child module of your crate, it’s a different crate entirely. This is the opposite tradeoff from #[cfg(test)] mod tests unit tests, which live inside the crate and can see everything.

Why this only applies to library crates

For use my_crate::add; to mean anything, there has to be a compiled library (an rlib) named my_crate for the test file to depend on. A src/lib.rs produces exactly that. A project with only a src/main.rs and no library target doesn’t — a binary crate isn’t a dependency anything can use, including your own tests/ files. If you want integration tests for logic that currently lives in main.rs, the standard move is to pull that logic into src/lib.rs and make main.rs a thin wrapper that calls into it:

#![allow(unused)]
fn main() {
// src/lib.rs
pub fn run() -> i32 {
    42
}
}
// src/main.rs
fn main() {
    println!("{}", my_crate::run());
}
#![allow(unused)]
fn main() {
// tests/smoke.rs
use my_crate::run;

#[test]
fn run_returns_the_expected_value() {
    assert_eq!(run(), 42);
}
}

Now tests/smoke.rs has something real to import, and main.rs stays a thin entry point.

Sharing setup code: tests/common/mod.rs

If two test files need the same setup helper, the naive move — a file called tests/common.rs — backfires: Cargo treats every direct child of tests/ as its own test crate, so common.rs gets compiled and run as a test binary too, showing up in cargo test output with zero tests in it. It’s harmless but noisy, and it’s not what you meant.

The idiomatic fix is a subdirectory using the old-style module file name, tests/common/mod.rs. Cargo only auto-discovers files directly inside tests/, not ones nested in a subdirectory — so tests/common/mod.rs is never treated as a test crate of its own. Each test file that wants it declares it explicitly with mod common;:

tests/
├── common/
│   └── mod.rs
├── api.rs
└── more_api.rs
#![allow(unused)]
fn main() {
// tests/common/mod.rs
pub fn setup() -> String {
    "test-fixture-value".to_string()
}
}
#![allow(unused)]
fn main() {
// tests/api.rs
mod common;

use my_crate::add;

#[test]
fn public_add_works() {
    let _fixture = common::setup();
    assert_eq!(add(2, 2), 4);
}
}

mod common; tells this particular test crate “compile the file at common/mod.rs as a module here” — it becomes part of tests/api.rs’s own crate, not a standalone test crate, so it never shows up as its own entry in the test summary.

Common mistakes

  • Expecting tests/ to see private items. It structurally can’t — each file is a separate crate that only sees your pub surface. If you need to check a private helper directly, that’s what a #[cfg(test)] mod tests unit test (previous lesson) is for.
  • Naming a shared helper file tests/common.rs. Cargo runs it as its own (nearly empty) test crate. Use tests/common/mod.rs instead so it’s only ever pulled in via mod common;.
  • Writing integration tests for a binary-only crate. With no src/lib.rs, there’s no library for tests/*.rs to use — move the logic you want to test into a library target first.
  • Underestimating the cost. Each file directly under tests/ triggers its own full compile of your library. A handful of files is fine; dozens of large integration test files can noticeably slow down cargo test.

More examples

Testing a shopping cart’s total from outside the crate

The public cart_total function is exactly what a checkout page would call, so the integration test calls it the same way — through my_crate::cart_total, nothing internal.

#![allow(unused)]
fn main() {
// src/lib.rs
pub fn cart_total(prices: &[f64]) -> f64 {
    prices.iter().sum()
}
}
#![allow(unused)]
fn main() {
// tests/cart.rs
use my_crate::cart_total;

#[test]
fn sums_every_item_in_the_cart() {
    let prices = vec![19.99, 5.50, 3.25];
    assert_eq!(cart_total(&prices), 28.74);
}
}

Testing a public password validator’s pass and fail cases

A signup form only cares whether is_valid_password returns true or false for real input — an integration test writes both cases exactly as a caller would see them.

#![allow(unused)]
fn main() {
// src/lib.rs
pub fn is_valid_password(password: &str) -> bool {
    password.len() >= 8 && password.chars().any(|c| c.is_ascii_digit())
}
}
#![allow(unused)]
fn main() {
// tests/password.rs
use my_crate::is_valid_password;

#[test]
fn accepts_a_password_with_a_digit_and_enough_length() {
    assert!(is_valid_password("orbit42launch"));
}

#[test]
fn rejects_a_password_with_no_digit() {
    assert!(!is_valid_password("orbitlaunch"));
}
}

Testing that a public parser’s error path actually returns Err

A config loader that silently accepts garbage input is worse than one that crashes loudly — the integration test checks both the Ok and Err paths a real caller would hit.

#![allow(unused)]
fn main() {
// src/lib.rs
pub fn parse_temperature(input: &str) -> Result<f32, String> {
    input
        .trim_end_matches('C')
        .parse::<f32>()
        .map_err(|_| format!("'{input}' is not a valid temperature"))
}
}
#![allow(unused)]
fn main() {
// tests/temperature.rs
use my_crate::parse_temperature;

#[test]
fn parses_a_valid_reading() {
    assert_eq!(parse_temperature("21.5C"), Ok(21.5));
}

#[test]
fn rejects_garbage_input() {
    assert!(parse_temperature("lukewarm").is_err());
}
}

Testing a blog-post slugifier against several titles

A URL slugifier has a lot of edge cases (spaces, punctuation, capitals) — looping over a table of titles in one integration test checks all of them without repeating the assertion.

#![allow(unused)]
fn main() {
// src/lib.rs
pub fn slugify(title: &str) -> String {
    title
        .to_lowercase()
        .chars()
        .map(|c| if c.is_alphanumeric() { c } else { '-' })
        .collect::<String>()
        .split('-')
        .filter(|s| !s.is_empty())
        .collect::<Vec<_>>()
        .join("-")
}
}
#![allow(unused)]
fn main() {
// tests/slug.rs
use my_crate::slugify;

#[test]
fn turns_a_post_title_into_a_url_slug() {
    let cases = [
        ("Rust for Humans!", "rust-for-humans"),
        ("  Leading   Spaces", "leading-spaces"),
    ];

    for (title, expected) in cases {
        assert_eq!(slugify(title), expected);
    }
}
}

Your turn

This integration test won’t compile — it’s reaching for something integration tests structurally can’t see.

#![allow(unused)]
fn main() {
// src/lib.rs
fn helper(x: i32) -> i32 {
    x * 2
}

pub fn double_and_add_one(x: i32) -> i32 {
    helper(x) + 1
}
}
#![allow(unused)]
fn main() {
// tests/api.rs
use my_crate::helper;

#[test]
fn helper_doubles() {
    assert_eq!(helper(5), 10);
}
}
Show solution

helper isn’t pub, and tests/api.rs is a separate crate — it can only import public items, so use my_crate::helper; fails with “function helper is private.” There are two legitimate fixes, and which one is right depends on intent.

If helper genuinely needs its own direct test, that’s a job for a unit test inside src/lib.rs, where use super::* can reach it:

#![allow(unused)]
fn main() {
// src/lib.rs
fn helper(x: i32) -> i32 {
    x * 2
}

pub fn double_and_add_one(x: i32) -> i32 {
    helper(x) + 1
}

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

    #[test]
    fn helper_doubles() {
        assert_eq!(helper(5), 10);
    }
}
}

But if the goal was really to check the crate’s public behavior, the integration test should exercise the pub function instead — that’s what an outside user would actually call:

#![allow(unused)]
fn main() {
// tests/api.rs
use my_crate::double_and_add_one;

#[test]
fn public_function_works() {
    assert_eq!(double_and_add_one(5), 11);
}
}

The rule of thumb: private implementation details get unit-tested from the inside; only your public API gets integration-tested from the outside.

Quick check

Remember this

  • Each .rs file directly under tests/ is compiled as its own separate crate, linked against your library exactly like an external user’s project would be.
  • Integration tests only see pub items — reach for a #[cfg(test)] unit test if you need private internals.
  • This only works for library crates: a binary-only crate (no src/lib.rs) has no importable public API.
  • tests/common/mod.rs shares setup helpers via mod common; without being auto-discovered as its own test file — tests/common.rs would be.
  • Each file in tests/ triggers its own compile of your crate, so a large integration suite can noticeably slow cargo test.

Go deeper

Next:

Doc tests and benchmarks

Intermediate · Runtime & ecosystem

What & why

Two tools, bundled here because they’re both about verifying claims instead of trusting them. A doc-test keeps your documentation’s example code honest — it’s compiled and executed by cargo test, so a stale or wrong example fails the build instead of silently rotting. A benchmark tells you whether a change actually made your code faster, backed by real measurement instead of a guess. std::time::Instant gives you a rough number for free; the criterion crate gives you a trustworthy one.

The idea, slowly

Doc-tests: examples that can’t lie

A fenced code block inside a /// doc comment isn’t just for show — cargo test compiles and runs it as its own tiny test:

#![allow(unused)]
fn main() {
/// Adds two numbers together.
///
/// # Examples
///
/// ```
/// assert_eq!(my_crate::add(2, 2), 4);
/// ```
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}
}

When you run cargo test, alongside your unit and integration tests you’ll see a section like Doc-tests my_crate ... test result: ok. 1 passed. Each ``` block gets compiled as its own standalone program (Rust wraps it in an implicit fn main if you don’t write one) and executed. If someone later changes add to subtract instead, this example now fails its assertion — cargo test catches the outdated documentation the same day, not months later when a user copy-pastes broken sample code.

Doc-tests carry the same visibility restriction as tests/ integration tests, and for the same reason: the example is compiled as if it were external code calling my_crate::add, so it can only reach pub items.

Hiding setup lines with #

Real examples often need setup code — imports, fixture construction — that would clutter the version a reader sees in rendered docs. Prefix a line with # (a literal # and a space) to compile and run it, while hiding it from the documentation output:

#![allow(unused)]
fn main() {
/// ```
/// # fn helper_setup() -> i32 { 40 }
/// let n = helper_setup();
/// assert_eq!(n + 2, 42);
/// ```
pub fn placeholder() {}
}

cargo doc renders only:

fn main() {
    let n = 40; // stands in for helper_setup(), shown for illustration
    assert_eq!(n + 2, 42);
    println!("n + 2 = {}", n + 2);
}

but cargo test still compiles and runs the hidden # fn helper_setup() -> i32 { 40 } line along with everything else. This is exactly how the standard library keeps its own doc examples both realistic and readable — imports and boilerplate get # -hidden, and the reader only sees the part that illustrates the point.

std::time::Instant — rough manual timing

For a quick “is this obviously slow” sanity check, Instant::now() and .elapsed() need nothing beyond std:

use std::time::Instant;

fn slow_sum(n: u64) -> u64 {
    (1..=n).sum()
}

fn main() {
    let start = Instant::now();
    let total = slow_sum(10_000_000);
    let elapsed = start.elapsed();

    println!("sum = {total}, took {elapsed:?}");
}

Instant::now() captures a monotonic timestamp — one that only ever moves forward, unaffected by the system clock being adjusted — and .elapsed() returns the Duration since that point. This is fine for eyeballing “does this take milliseconds or seconds,” but a single measurement is noisy: CPU frequency scaling, other processes, and cold caches can all swing one run by 2x or more. Don’t trust it to answer “did my optimization actually help.”

criterion — real statistically-sound benchmarking

Stable Rust has no built-in cargo bench. The original #[bench] attribute and cargo bench combo is part of the unstable test crate, nightly-only. For stable Rust, the ecosystem standard is the criterion crate: it runs your function thousands of times, applies statistical analysis to filter out noise, and — most usefully — compares each run against the previous run, reporting something like “4% faster, confidence interval doesn’t include zero” instead of a single raw number.

# Cargo.toml
[dev-dependencies]
criterion = "0.5"

[[bench]]
name = "my_benchmark"
harness = false
#![allow(unused)]
fn main() {
// benches/my_benchmark.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};

fn fibonacci(n: u64) -> u64 {
    match n {
        0 => 0,
        1 => 1,
        n => fibonacci(n - 1) + fibonacci(n - 2),
    }
}

fn bench_fibonacci(c: &mut Criterion) {
    c.bench_function("fib 20", |b| b.iter(|| fibonacci(black_box(20))));
}

criterion_group!(benches, bench_fibonacci);
criterion_main!(benches);
}
cargo bench

black_box stops the compiler from being “too smart” — without it, the optimizer can see the result of fibonacci(20) is never used and delete the entire computation. criterion_group!/criterion_main! generate the fn main for this file, since it compiles as its own binary under benches/, the same way each tests/*.rs file compiles as its own crate.

Common mistakes

  • Assuming a bare ``` fence in a doc comment is just illustrative. It’s compiled and run by cargo test by default. To show non-runnable or non-Rust code, use ```text or mark the block ```ignore.
  • Doc-testing something private. Doc-tests only see pub items, the same restriction as integration tests — there’s nothing to test if the item isn’t public.
  • Trusting one Instant::now()/.elapsed() measurement. Background noise can swing a single run wildly. Run it several times and eyeball the spread, or better, reach for criterion.
  • Expecting cargo bench to work out of the box on stable. The built-in #[bench]/cargo bench pair is nightly-only; on stable you need criterion (or a similar crate) with harness = false.
  • Forgetting black_box. Without it, a hand-rolled micro-benchmark can have its entire body optimized away, since the compiler sees the result is never observably used — you end up benchmarking nothing.

More examples

A doc-tested string utility

A reverse function’s doc-test doubles as its example and its proof — if someone breaks the logic, cargo test catches it in the same place the example lives.

#![allow(unused)]
fn main() {
/// Reverses a string.
///
/// # Examples
///
/// ```
/// assert_eq!(my_crate::reverse("stressed"), "desserts");
/// ```
pub fn reverse(s: &str) -> String {
    s.chars().rev().collect()
}
}

Hiding setup so a median doc-test reads clean

A median function’s doc-test needs a slice already built before the interesting assertion — # -hiding that setup line keeps the rendered docs down to just the part that matters.

#![allow(unused)]
fn main() {
/// Returns the median of a sorted slice of numbers.
///
/// # Examples
///
/// ```
/// # let scores = vec![70, 82, 88, 91, 95];
/// assert_eq!(my_crate::median(&scores), 88);
/// ```
pub fn median(sorted: &[i32]) -> i32 {
    sorted[sorted.len() / 2]
}
}

Timing a prime-counting loop

A prime-counting loop is exactly the kind of thing worth sanity-checking with a raw Instant reading before reaching for anything heavier.

use std::time::Instant;

fn is_prime(n: u64) -> bool {
    if n < 2 {
        return false;
    }
    let mut i = 2;
    while i * i <= n {
        if n % i == 0 {
            return false;
        }
        i += 1;
    }
    true
}

fn main() {
    let start = Instant::now();
    let count = (2..200_000u64).filter(|&n| is_prime(n)).count();
    let elapsed = start.elapsed();

    println!("found {count} primes under 200,000 in {elapsed:?}");
}

Two ways to find a value in a sorted Vec have very different growth rates — criterion measures both under the same conditions instead of trusting a guess about which is faster.

[dev-dependencies]
criterion = "0.5"

[[bench]]
name = "search_bench"
harness = false
#![allow(unused)]
fn main() {
use criterion::{black_box, criterion_group, criterion_main, Criterion};

fn linear_search(haystack: &[i32], needle: i32) -> bool {
    haystack.iter().any(|&x| x == needle)
}

fn binary_search(haystack: &[i32], needle: i32) -> bool {
    haystack.binary_search(&needle).is_ok()
}

fn bench_search(c: &mut Criterion) {
    let data: Vec<i32> = (0..10_000).collect();

    c.bench_function("linear search", |b| {
        b.iter(|| linear_search(black_box(&data), black_box(9_999)))
    });
    c.bench_function("binary search", |b| {
        b.iter(|| binary_search(black_box(&data), black_box(9_999)))
    });
}

criterion_group!(benches, bench_search);
criterion_main!(benches);
}

Your turn

This doc-test compiles fine but fails when cargo test actually runs it.

#![allow(unused)]
fn main() {
/// Doubles a number.
///
/// ```
/// assert_eq!(my_crate::double(3), 5);
/// ```
pub fn double(x: i32) -> i32 {
    x * 2
}
}
Show solution

double(3) is 6, not 5 — the doc example asserts the wrong value. This is exactly the class of bug doc-tests exist to catch: a wrong example doesn’t just mislead a reader, it fails cargo test.

#![allow(unused)]
fn main() {
/// Doubles a number.
///
/// ```
/// assert_eq!(my_crate::double(3), 6);
/// ```
pub fn double(x: i32) -> i32 {
    x * 2
}
}

Run cargo test and you’d see a Doc-tests section fail with the assertion panic, pointing at this exact doc comment — the same experience as any other failing test, just sourced from documentation instead of tests/ or a #[test] function.

Quick check

Remember this

  • A fenced code block inside a /// doc comment is compiled AND executed by cargo test — a broken example fails the build, not just the docs.
  • Prefix a line with # to compile-and-run it while hiding it from rendered documentation — ideal for imports and setup.
  • std::time::Instant::now() + .elapsed() gives rough, noisy manual timing — good for “is this obviously too slow,” not for real comparisons.
  • Stable Rust has no built-in cargo bench; the criterion crate is the standard for statistically sound benchmarks.
  • criterion compares each run to the last and reports whether a change is a real regression or just noise — and black_box keeps the optimizer from deleting what you’re trying to measure.

Go deeper

Next:

File I/O

Intermediate · Runtime & ecosystem

What & why

Reading and writing files is how a program remembers things after it closes. Rust’s std::fs module gives you direct, no-magic functions for this — but every one of them can fail (the file might be missing, locked, or full disk), so Rust forces you to handle that failure. This lesson is really two lessons in a trench coat: files, and the Result error handling that files demand.

The idea, slowly

Talking to a file is talking to the outside world, and the outside world is unreliable. The disk could be full. The file could have been deleted a millisecond ago. You might not have permission. Because any of these can happen, almost every file function in Rust returns a Result — a value that is either Ok(the_data) or Err(what_went_wrong). Rust will not let you use the data until you’ve said what happens in the Err case. That’s the whole “treat failure as part of the design” idea.

Reading a whole file into a String

The simplest possible read:

// This needs a real file on disk — run it in a cargo project, not the Playground.
use std::fs;

fn main() {
    let text = fs::read_to_string("notes.txt")
        .expect("could not read notes.txt");
    println!("The file says: {}", text);
}

fs::read_to_string("notes.txt") returns a Result<String, io::Error>. The .expect("...") says: “If this is Ok, hand me the String inside. If it’s Err, crash and print my message.” .expect is fine for tiny scripts, but crashing is rude in real software — we’ll do better in a moment.

The compiler is thinking: “This function can fail. I will hand the human a Result, and I refuse to let them pretend the failure can’t happen. They must unwrap it, .expect it, or propagate it.”

Writing a String to a file

// Needs a real filesystem — run in a cargo project.
use std::fs;

fn main() {
    fs::write("greeting.txt", "Hello from Rust!")
        .expect("could not write greeting.txt");
    println!("Wrote the file.");
}

fs::write creates the file if it doesn’t exist, or overwrites it completely if it does. There is no “oops” — if the file was there, its old contents are gone. If you want to add to a file instead of replacing it, you open it in append mode (shown below).

The ? operator — the grown-up way to handle failure

Sprinkling .expect everywhere means your program panics at the first hiccup. The professional pattern is to let errors bubble up to the caller using the ? operator. ? means: “If this is Ok, give me the value and keep going. If it’s Err, stop this function right now and return that error to whoever called me.”

// Needs a real filesystem — run in a cargo project.
use std::fs;
use std::io;

fn load_notes() -> Result<String, io::Error> {
    let text = fs::read_to_string("notes.txt")?;   // ? here
    Ok(text)
}

fn main() {
    match load_notes() {
        Ok(text) => println!("Notes: {}", text),
        Err(e)   => println!("Sorry, couldn't load notes: {}", e),
    }
}

Notice that a function using ? must itself return a Result (or Option), because ? needs somewhere to send the error. That’s why load_notes returns Result<String, io::Error>. This is the single most common shape of real Rust I/O code.

Appending instead of overwriting

When you want to add lines to a log without erasing it, open the file with OpenOptions:

// Needs a real filesystem — run in a cargo project.
use std::fs::OpenOptions;
use std::io::Write;

fn main() -> std::io::Result<()> {
    let mut file = OpenOptions::new()
        .create(true)   // make it if missing
        .append(true)   // add to the end, don't erase
        .open("log.txt")?;

    writeln!(file, "another log line")?;   // writeln! adds a newline
    Ok(())
}

See how main itself returns std::io::Result<()>? Rust lets main return a Result so you can use ? right inside it. () (the empty tuple) means “on success there’s no interesting value to return, just the fact that it worked.”

Why can’t I just run these on the Playground?

The Rust Playground has no real filesystem you can trust — there’s no notes.txt sitting there, and writes vanish. So the blocks above are marked to show nicely without a broken Run button. To actually try file I/O, make a tiny project with cargo new fileplay, drop the code into src/main.rs, put a notes.txt next to Cargo.toml, and run cargo run.

One thing you can run: handling a Result

Error handling itself doesn’t need a filesystem. Here’s a runnable program that mimics the exact shape of file code — a function that returns a Result, unwrapped with match:

// Pretend this is "reading a file" — it returns Ok or Err just like fs::read_to_string.
fn read_config(name: &str) -> Result<String, String> {
    if name == "config.txt" {
        Ok(String::from("theme=dark"))
    } else {
        Err(format!("no such file: {}", name))
    }
}

fn main() {
    match read_config("config.txt") {
        Ok(contents) => println!("Loaded: {}", contents),
        Err(problem) => println!("Failed: {}", problem),
    }

    match read_config("missing.txt") {
        Ok(contents) => println!("Loaded: {}", contents),
        Err(problem) => println!("Failed: {}", problem),
    }
}

Press Run. You’ll see one success and one failure — exactly the two paths real file code deals with.

Common mistakes

  • Ignoring the Result. If you call fs::write(...) and never look at the result, Rust warns you (unused Result that must be used). That warning is real — you just threw away the answer to “did it actually work?”
  • Using .unwrap()/.expect() in real programs. They crash the whole program on the first error. Fine for a throwaway script; bad for anything a user touches. Prefer ? and handle the error where it makes sense.
  • Forgetting that fs::write overwrites. It replaces the entire file. If you meant to add to it, you needed OpenOptions::new().append(true).
  • Using ? in a function that returns (). ? needs to return an error somewhere. The function it lives in must return Result (or Option), including main if you use ? there.
  • Assuming a path is relative to your source file. File paths are relative to where the program is run from (the working directory), not where the .rs file lives. This trips up everyone once.

More examples

Counting lines in a server access log

Log files are read far more often than they’re parsed structurally — .lines().count() answers “how many requests came in” without building anything fancier.

// Needs a real filesystem — run in a cargo project.
use std::fs;

fn main() {
    let text = fs::read_to_string("access.log").expect("could not read access.log");
    let line_count = text.lines().count();
    println!("access.log has {line_count} entries");
}

Backing up a save file before overwriting it

A game that’s about to write new save data first wants a safety copy — fs::copy duplicates a file in one call instead of a manual read-then-write.

// Needs a real filesystem — run in a cargo project.
use std::fs;

fn main() {
    fs::copy("save.dat", "save.dat.bak")
        .expect("could not back up save.dat");
    println!("backup written to save.dat.bak");
}

Refusing to clobber an existing export file

A “generate report” button that silently overwrites yesterday’s export is a bug waiting to be reported — checking Path::exists() first lets the program refuse instead of destroying data.

// Needs a real filesystem — run in a cargo project.
use std::fs;
use std::path::Path;

fn main() {
    let path = "report.csv";
    if Path::new(path).exists() {
        println!("refusing to overwrite {path} — it already exists");
    } else {
        fs::write(path, "id,total\n").expect("could not create report.csv");
        println!("wrote a fresh {path}");
    }
}

Cleaning up a temp file after a batch job

A batch job that writes intermediate scratch data shouldn’t leave it lying around when it’s done — fs::remove_file deletes it explicitly instead of hoping the OS cleans up.

// Needs a real filesystem — run in a cargo project.
use std::fs;

fn main() {
    let tmp_path = "batch_job.tmp";
    fs::write(tmp_path, "intermediate data").expect("could not write temp file");

    // ... batch job would read/process the temp file here ...

    fs::remove_file(tmp_path).expect("could not remove temp file");
    println!("cleaned up {tmp_path}");
}

Your turn

This is a “spot the bug” exercise (file I/O can’t run on the Playground). The function below is supposed to read a file and return its contents, but it won’t compile. What’s wrong, and how do you fix it?

use std::fs;

fn load(path: &str) -> String {
    let text = fs::read_to_string(path)?;   // hmm...
    text
}
Show solution

The ? operator can only be used in a function that returns a Result (or Option), because ? needs somewhere to send the error if the read fails. This function claims to return a plain String, so there’s nowhere for the error to go — the compiler rejects it.

Fix it by making the return type a Result:

use std::fs;
use std::io;

fn load(path: &str) -> Result<String, io::Error> {
    let text = fs::read_to_string(path)?;   // ? now has somewhere to return an error
    Ok(text)                                // success case must be wrapped in Ok
}

Now ? works: on success it unwraps the String, and on failure it returns the io::Error to the caller. Note the success value also had to be wrapped in Ok(...).

Quick check

Remember this

  • Almost every std::fs function returns a Result because file access can always fail.
  • fs::read_to_string(path) reads a whole file; fs::write(path, data) writes (and overwrites) one.
  • The ? operator unwraps Ok or early-returns Err — but only inside a function that returns Result/Option.
  • Use OpenOptions::new().append(true) to add to a file instead of erasing it.
  • Prefer ? over .unwrap()/.expect() in real programs so failures are handled, not crashes.

Go deeper

Next:

Environment variables and config

Beginner · Runtime & ecosystem

What & why

Almost every real program needs settings that live outside the binary — a port number, an API key, a database URL, a “dev vs. production” switch. You don’t want to recompile your app just to change a port. Rust gives you three layers for this, from simplest to richest: read a single environment variable directly, load a whole .env file for local development, or deserialize a structured TOML/JSON config file into a real Rust struct. All three treat “the setting is missing” as something you must handle, not something that silently becomes an empty string.

The idea, slowly

std::env::var returns a Result, not a String

std::env::var("KEY") looks up an environment variable and hands back Result<String, VarError>Ok(value) if it’s set, Err(VarError::NotPresent) if it isn’t. This is a deliberate design choice: in many languages, reading a missing environment variable silently gives you "" or undefined, and your program limps along with a blank setting instead of crashing where the mistake actually happened. Rust refuses to let “missing” and “set to empty string” look the same.

use std::env;

fn main() {
    // This var is almost certainly not set inside the Playground's sandbox —
    // so you'll see the Err branch run.
    match env::var("PORT") {
        Ok(val) => println!("PORT = {val}"),
        Err(e) => println!("PORT not set: {e}"),
    }

    // A very common real pattern: fall back to a sane default instead of
    // crashing when a var is optional.
    let port: u16 = env::var("PORT")
        .unwrap_or_else(|_| "8080".into())
        .parse()
        .expect("PORT must be a number");
    println!("listening on {port}");
}

Run it. The first match prints the Err branch, and e prints as environment variable not found — that’s VarError’s own Display message. The compiler is thinking: “Reading the outside world can fail. I’m not going to let you treat the result as a guaranteed String — you get a Result, and you decide what ‘missing’ means for this variable.” Some vars are truly required (crash with a clear message if absent, via .expect("EXPLAIN_WHAT")); others are optional (fall back to a default, like PORT above).

Loading a .env file for local development

In production, real environment variables are set by your platform (Docker, systemd, your cloud host’s dashboard). But typing export API_KEY=abc123 in every new terminal during local development gets old fast. The dotenvy crate reads a .env file from your project root and copies its key-value pairs into the process’s environment — so std::env::var finds them exactly as if you’d exported them yourself.

cargo add dotenvy
# .env (project root)
API_KEY=dev-only-fake-key
DATABASE_URL=postgres://localhost/myapp_dev
// dotenvy is an external crate — add it first (above), then run in a real project.
use std::env;

fn main() {
    dotenvy::dotenv().ok(); // loads .env into the process environment; ignores it if missing
    let api_key = env::var("API_KEY").expect("API_KEY must be set — check your .env file");
    println!("using key starting with {}", &api_key[..4.min(api_key.len())]);
}

Call dotenvy::dotenv() once, right at the top of main, before you read anything with env::var. The .ok() throws away the Result on purpose — if there’s no .env file (common in production, where real env vars are already set another way), that’s not an error, it’s expected.

Why real secrets don’t belong in a committed .env file: a .env file sitting in your repo gets committed to git the first time someone forgets to add it to .gitignore — and once a secret is in git history, rotating it is the only real fix, because it’s in every clone and every fork forever. Treat .env as a local development convenience for fake or low-stakes values, add it to .gitignore, and commit a .env.example with the key names but no real values. Production secrets belong in your platform’s actual secret manager (environment variables set in your host’s dashboard, a secrets vault, CI secret storage) — never in a file that git add . can pick up.

Structured config: deserialize a file into a struct

A handful of env vars is fine. A dozen related settings — server port, log level, feature flags, timeouts — turns into a wall of env::var(...) calls that’s easy to get wrong. The idiomatic fix is to describe your config as a Rust struct and let serde deserialize a TOML (or JSON) file straight into it.

cargo add serde --features derive
cargo add toml
# config.toml
port = 8080
debug = false
app_name = "orbit"
// serde + toml are external crates — add them first (above), then run in a real project.
use serde::Deserialize;

#[derive(Deserialize, Debug)]
struct Config {
    port: u16,
    debug: bool,
    app_name: String,
}

fn main() {
    let text = std::fs::read_to_string("config.toml").expect("could not read config.toml");
    let config: Config = toml::from_str(&text).expect("config.toml is not valid");
    println!("{config:?}");
}

Notice the shape: #[derive(Deserialize)] teaches toml::from_str how to turn text into a Config — field names in the file line up with field names on the struct, and each field’s type (u16, bool, String) is checked for you. Get the TOML wrong (wrong type, missing required field) and you get one clear Err at startup instead of a None-shaped bug three functions later. The exact same struct works with serde_json::from_str if you’d rather ship JSON — the struct doesn’t know or care which text format fed it.

Common mistakes

  • Assuming a missing env var reads as "". It doesn’t — env::var returns Err. Code that does env::var("KEY").unwrap_or_default() silently treats “forgot to set this” the same as “set to empty on purpose,” which hides real misconfiguration.
  • Committing a .env file with real secrets. Once it’s in git history, the secret is compromised — rotate it, don’t just delete the file. Keep .env in .gitignore; commit a .env.example instead.
  • Calling dotenvy::dotenv() after you’ve already read the vars you need. It has to run before the env::var calls that depend on it, right at the top of main.
  • Using bare .unwrap() on a required env var. It crashes with called \Result::unwrap()` on an `Err` value: NotPresent— technically correct but useless at 2am..expect(“DATABASE_URL must be set”)` tells you exactly what to fix.
  • Reaching for a dozen loose env::var calls instead of one config struct. Once you have more than two or three related settings, a #[derive(Deserialize)] struct is easier to validate, document, and pass around than scattered string lookups.

More examples

Switching log verbosity between dev and prod

A CLI tool that behaves the same everywhere is annoying to debug locally and noisy in production — reading one APP_ENV variable lets the same binary do both.

fn main() {
    let mode = std::env::var("APP_ENV").unwrap_or_else(|_| "development".into());

    match mode.as_str() {
        "production" => println!("[prod] starting with minimal logging"),
        "staging" => println!("[staging] starting with verbose logging"),
        _ => println!("[dev] starting with debug logging enabled"),
    }
}

Enabling a hidden debug overlay in a game

Some flags don’t need a value at all — checking .is_ok() instead of reading the value turns “does this variable exist” into a simple on/off switch for a debug overlay.

fn main() {
    let hitboxes_on = std::env::var("DEBUG_HITBOXES").is_ok();

    if hitboxes_on {
        println!("rendering hitbox outlines for every sprite");
    } else {
        println!("normal rendering — no debug overlay");
    }
}

Loading SMTP credentials for an email worker

A background worker that sends mail needs real credentials in production but fake ones on your laptop — dotenvy fills in the fake ones from .env without touching how the worker reads them.

// dotenvy is an external crate — cargo add dotenvy, then run in a real project.
use std::env;

fn main() {
    dotenvy::dotenv().ok();

    let host = env::var("SMTP_HOST").expect("SMTP_HOST must be set — check your .env file");
    let user = env::var("SMTP_USER").expect("SMTP_USER must be set — check your .env file");

    println!("connecting to {host} as {user}");
}

Configuring a game server from a TOML file

A multiplayer server has too many related settings for loose env vars — deserializing a server.toml straight into a struct catches a bad tick_rate at startup instead of mid-match.

// serde + toml are external crates — cargo add serde --features derive, cargo add toml.
use serde::Deserialize;

#[derive(Deserialize, Debug)]
struct ServerConfig {
    max_players: u32,
    tick_rate: f32,
    region: String,
}

fn main() {
    let text = std::fs::read_to_string("server.toml").expect("could not read server.toml");
    let config: ServerConfig = toml::from_str(&text).expect("server.toml is not valid");
    println!("{config:?}");
}

Your turn

This function is supposed to read PORT from the environment, falling back to 8080 if it’s missing — but it doesn’t compile. Find the bug before checking the solution.

use std::env;

fn main() {
    let port: u16 = env::var("PORT").parse().expect("PORT must be a number");
    println!("listening on {port}");
}
Show solution

env::var("PORT") returns Result<String, VarError> — not a String. .parse() is a method on str/String, not on Result, so the compiler rejects this with something like no method named \parse` found for enum `Result<String, VarError>` in the current scope. The Result` has to be dealt with before you can parse the string inside it.

use std::env;

fn main() {
    let port: u16 = env::var("PORT")
        .unwrap_or_else(|_| "8080".into()) // unwrap the Result into a String first
        .parse()                           // now .parse() has a &str to work with
        .expect("PORT must be a number");
    println!("listening on {port}");
}

.unwrap_or_else(|_| "8080".into()) turns the Result<String, VarError> into a plain String — either the real value or the fallback — and then .parse() has something it actually knows how to work with.

Quick check

Remember this

  • std::env::var("KEY") returns Result<String, VarError> — a missing variable is an Err, never a silent empty string.
  • dotenvy::dotenv().ok() at the top of main loads a .env file into the process environment for local dev convenience — call it before any env::var reads that depend on it.
  • Never commit real secrets in .env — keep it in .gitignore, commit a .env.example instead, and put production secrets in your platform’s real secret manager.
  • For more than a couple of related settings, deserialize a TOML/JSON file into a #[derive(Deserialize)] struct instead of many loose env::var calls.
  • Prefer .expect("clear message") over bare .unwrap() on required config — future-you (or 2am on-call you) will thank you.

Go deeper

Next:

Args, exit codes, and subprocesses

Intermediate · Runtime & ecosystem

What & why

A command-line program talks to the outside world in three ways: it reads arguments the user typed, it reports success or failure through its exit code, and sometimes it shells out to another program entirely. std::env::args(), std::process::ExitCode, and std::process::Command are the three tools for each job — and each has a sharp edge that trips people up the first time.

The idea, slowly

std::env::args() — the first element is the program name

std::env::args() returns an iterator of Strings: your program’s raw command-line arguments. The gotcha is that the very first element is always the path/name of the program itself, not the first real argument — exactly like argv[0] in C. Forget this and your “first argument” is actually your own binary’s name.

fn main() {
    let all_args: Vec<String> = std::env::args().collect();
    println!("raw args (index 0 is the program itself): {all_args:?}");

    // Skip the program name to get the arguments a user actually typed.
    let real_args: Vec<String> = std::env::args().skip(1).collect();
    println!("real args: {real_args:?}");
}

On the Playground you’ll see all_args holds just one element (the program path) since no arguments were passed — which is exactly the point: index 0 is never a “real” argument, it’s metadata about the process. Real CLI code almost always starts with .skip(1) before parsing.

ExitCode vs. std::process::exit — one of them skips cleanup

A process reports success or failure to whatever invoked it (a shell, a CI pipeline, another program) through its exit code: 0 conventionally means success, anything else means failure. Rust gives you two ways to set it, and they are not interchangeable:

  • Return std::process::ExitCode from main. This is a normal function return — everything on the stack gets cleaned up (Drop runs) exactly like returning from any other function.
  • Call std::process::exit(code). This terminates the process immediately, wherever it’s called from. It never returns, and — this is the part that surprises people — it does not run Drop for anything still on the stack.
struct Guard;

impl Drop for Guard {
    fn drop(&mut self) {
        println!("cleaning up");
    }
}

fn main() {
    let _guard = Guard;
    println!("about to call process::exit");
    std::process::exit(0); // terminates right here — "cleaning up" never prints
}

Now compare it to returning ExitCode normally:

use std::process::ExitCode;

struct Guard;

impl Drop for Guard {
    fn drop(&mut self) {
        println!("cleaning up");
    }
}

fn main() -> ExitCode {
    let _guard = Guard;
    println!("about to return ExitCode");
    ExitCode::SUCCESS // main returns normally — Drop runs on the way out
}

Run both. The first never prints “cleaning up” — process::exit cut the process off before _guard’s destructor got a chance to run. The second does, because returning is a normal function exit. If your program holds anything that needs cleanup on exit — a temp file to delete, a lock to release, a buffered writer to flush — prefer returning ExitCode from main (or from a helper that main calls) over reaching for process::exit mid-function.

Command — spawning another program

std::process::Command builds and runs a subprocess: another program entirely, running as its own OS process. You get two very different ways to run it:

  • .output() captures the subprocess’s stdout and stderr into memory and hands them back to you as Vec<u8>, along with the exit status. Nothing the subprocess prints appears on your program’s own terminal — you get it as data instead.
  • .status() lets the subprocess inherit your program’s stdin/stdout/stderr directly — its output goes straight to the real terminal, just like if you’d typed the command yourself — and you only get back the exit status, no captured text.
// Spawning processes isn't allowed on the Playground — run this in a real project.
use std::process::Command;

fn main() {
    let output = Command::new("echo")
        .arg("captured, not printed directly")
        .output()
        .expect("failed to run echo");

    println!("stdout was: {}", String::from_utf8_lossy(&output.stdout));
    println!("exit status: {}", output.status);
}
// Spawning processes isn't allowed on the Playground — run this in a real project.
use std::process::Command;

fn main() {
    // .status() inherits the terminal directly — this line prints itself,
    // you never see it as a String in your own program.
    let status = Command::new("echo")
        .arg("prints straight to the terminal")
        .status()
        .expect("failed to run echo");

    println!("exited with: {status}");
}

Reach for .output() when you need to do something with what the subprocess printed (parse it, log it, check for a specific error string). Reach for .status() when you just want the subprocess to behave like a normal command the user is watching run — a linter, a build tool, anything where its own progress output is useful as-is.

Common mistakes

  • Forgetting to skip args()[0]. Parsing “the first argument” without .skip(1) silently treats your own program’s path as the user’s first input.
  • Calling std::process::exit() when something needs to run Drop first. It skips destructors for everything still on the stack — files may not flush, locks may not release. Prefer returning ExitCode from main so cleanup runs normally.
  • Assuming .output() also prints to the terminal. It doesn’t — the subprocess’s output is captured into memory as bytes, not shown to the user. If you want the user to see it, you have to println! it yourself (or use .status() instead).
  • Ignoring the exit status. A subprocess can run successfully (no error spawning it) but still fail its own job (nonzero exit code) — always check output.status.success() or status.success() rather than assuming “it ran” means “it worked.”
  • Buffering huge output with .output(). It holds all of stdout/stderr in memory at once — fine for a linter’s summary, risky for a subprocess that streams megabytes of logs.

More examples

Counting repeated verbosity flags

A CLI that supports -v -v -v for increasing verbosity just counts how many times the flag shows up in the skipped argument list.

fn main() {
    // Run locally as `myapp -v -v -v input.txt` to see verbosity climb.
    let real_args: Vec<String> = std::env::args().skip(1).collect();
    let verbosity = real_args.iter().filter(|a| a.as_str() == "-v").count();
    println!("verbosity level: {verbosity}");
}

Distinct exit codes for different failures

A conversion tool can signal why it failed — no arguments versus a bad file type — with different ExitCode values, which lets a calling script react differently to each case.

use std::process::ExitCode;

fn main() -> ExitCode {
    let args: Vec<String> = std::env::args().skip(1).collect();

    if args.is_empty() {
        eprintln!("usage: convert <file>");
        return ExitCode::from(2); // usage error
    }
    if !args[0].ends_with(".csv") {
        eprintln!("error: expected a .csv file");
        return ExitCode::from(1); // processing error
    }
    println!("converting {}", args[0]);
    ExitCode::SUCCESS
}

Searching a log file and reading grep’s exit status

grep exits 1 when it simply finds no matches — not a crash — so a tool that shells out to it has to treat a nonzero status as “nothing found,” not “something broke.”

// Spawning processes isn't allowed on the Playground — run this in a real project.
use std::process::Command;

fn main() {
    let output = Command::new("grep")
        .args(["ERROR", "app.log"])
        .output()
        .expect("failed to run grep");

    // grep exits 1 when it simply finds nothing — that's not a crash, just "no matches".
    if output.status.success() {
        println!("found errors:\n{}", String::from_utf8_lossy(&output.stdout));
    } else {
        println!("no ERROR lines in app.log");
    }
}

Running cargo fmt as a pre-commit check

.status() lets cargo fmt --check’s own diff print straight to the terminal, so a pre-commit hook can just check the exit status and let the user see exactly what’s unformatted.

// Spawning processes isn't allowed on the Playground — run this in a real project.
use std::process::Command;

fn main() {
    // .status() inherits the terminal, so cargo fmt's own diff output shows up as-is.
    let status = Command::new("cargo")
        .args(["fmt", "--check"])
        .current_dir("path/to/project")
        .status()
        .expect("failed to run cargo fmt");

    if !status.success() {
        eprintln!("code isn't formatted — run `cargo fmt` before committing");
    }
}

Your turn

This program is supposed to print a usage message and fail with a nonzero exit code when no argument is given — but it doesn’t compile.

use std::process::ExitCode;

fn main() -> ExitCode {
    let args: Vec<String> = std::env::args().skip(1).collect();
    if args.is_empty() {
        eprintln!("usage: tool <name>");
        return; // bug!
    }
    println!("hello, {}", args[0]);
    ExitCode::SUCCESS
}
Show solution

main is declared to return ExitCode, so every path out of the function must produce an ExitCode — including the early return. A bare return; returns () (the empty tuple), not an ExitCode, so the compiler rejects it with a type mismatch: expected \ExitCode`, found `()``.

use std::process::ExitCode;

fn main() -> ExitCode {
    let args: Vec<String> = std::env::args().skip(1).collect();
    if args.is_empty() {
        eprintln!("usage: tool <name>");
        return ExitCode::FAILURE; // now every path returns an ExitCode
    }
    println!("hello, {}", args[0]);
    ExitCode::SUCCESS
}

The fix is to return an actual ExitCode value on the early-exit path too — ExitCode::FAILURE signals “this run didn’t succeed” to whatever invoked the program, exactly the way a nonzero exit code should.

Quick check

Remember this

  • std::env::args() always includes the program name as the first element — skip it (.skip(1)) before parsing real arguments.
  • Return std::process::ExitCode from main instead of calling std::process::exit mid-function — returning still runs Drop for everything on the stack; exit skips it entirely.
  • Command::new("prog").arg("x").output() captures stdout/stderr/exit status as data; .status() inherits the parent’s real streams and only returns the exit status.
  • A subprocess can spawn successfully but still fail its job — always check .status.success(), don’t assume “it ran” means “it worked.”
  • .output() buffers everything in memory — fine for small output, risky for a subprocess producing a lot of it.

Go deeper

Next:

Threads and spawn

Intermediate · Runtime & ecosystem

What & why

Most programs run one instruction at a time, on one thread. std::thread::spawn lets you hand a chunk of work to a second worker that runs at the same time as the rest of your program — genuinely at the same instant, if your machine has more than one CPU core. It hands you back a JoinHandle: a receipt you can trade in later for the thread’s result. Two things make this safe in Rust: the closure you spawn almost always has to be move, and a crash inside that thread doesn’t take your whole program down with it.

The idea, slowly

Spawning a thread

Think of main as your first worker. thread::spawn hires a second one and hands it a closure to run on its own.

use std::thread;

fn main() {
    let handle = thread::spawn(|| {
        // this closure runs on a NEW thread
        for i in 1..=3 {
            println!("  [worker] step {i}");
        }
    });

    println!("[main] spawned a worker");

    handle.join().unwrap(); // wait here until the worker finishes
    println!("[main] worker is done");
}

Press Run a few times and watch the order shuffle a little — [main] spawned a worker and [worker] step 1 can interleave differently each run, because they’re genuinely racing. thread::spawn returns immediately with a JoinHandle; it does not wait for the closure to finish. .join() is what pauses the current thread until the spawned one completes. Skip it, and main might exit while the worker is still mid-sentence — when main ends, the whole process ends, cutting the worker off.

Why the closure usually needs move

Here’s where Rust gets strict. If the closure uses a value that main also owns, this is a compile error:

use std::thread;

fn main() {
    let name = String::from("Shamirul");

    let handle = thread::spawn(|| {
        println!("worker sees {name}");   // ERROR: closure may outlive `name`
    });

    handle.join().unwrap();
}

The compiler’s reasoning: “This closure borrows name. thread::spawn needs the closure to live for 'static — it might run for as long as it wants, possibly outliving this function’s stack frame. I can’t prove name will still be alive when the thread reads it. I won’t allow it.” The fix is to move ownership of name into the thread:

use std::thread;

fn main() {
    let name = String::from("Shamirul");

    let handle = thread::spawn(move || {   // `move` gives the thread ownership
        println!("worker sees {name}");
    });

    handle.join().unwrap();
    // `name` now belongs to the thread; main can't use it anymore.
}

move says “hand this value over to the new thread entirely.” Now the thread owns name outright — there’s no borrowed reference that could dangle, so the compiler is happy. This is why you’ll see thread::spawn(move || { ... }) far more often than thread::spawn(|| { ... }) in real code: as soon as the closure touches anything from the outside, it needs to own it.

JoinHandle and .join(): getting a value back

A spawned closure can return a value, not just print things. Whatever the closure evaluates to (its last expression) becomes the thread’s result:

use std::thread;

fn main() {
    let handle = thread::spawn(|| {
        let mut total = 0;
        for i in 1..=10 {
            total += i;
        }
        total // this becomes the thread's result
    });

    match handle.join() {
        Ok(total) => println!("sum = {total}"),
        Err(_) => println!("the thread panicked"),
    }
}

.join() itself returns a Result<T, Box<dyn Any + Send + 'static>>not the bare value T. Ok(total) carries whatever the closure returned; Err(payload) only shows up if the thread panicked, and payload is the panic’s message, boxed up. That’s why .join().unwrap() is so common in small examples: it says “I’m confident this thread won’t panic, just give me the value” — but it’s worth knowing that unwrap is skipping over a real Result, not a formality.

A panic in a spawned thread doesn’t crash the whole program

This is the part that surprises people coming from languages where any uncaught error kills the process. In Rust, each thread unwinds on its own:

use std::thread;

fn main() {
    let handle = thread::spawn(|| {
        let v = vec![1, 2, 3];
        println!("{}", v[10]); // out of bounds: this panics
    });

    match handle.join() {
        Ok(_) => println!("worker finished normally"),
        Err(_) => println!("worker panicked, but main is still alive"),
    }

    println!("main keeps going after the crash was contained");
}

Press Run. You’ll see Rust’s usual panic message printed (that’s the runtime reporting the crash, same as any panic), and then — critically — main keeps executing. The panic only unwound that thread’s stack; .join() caught it as Err instead of propagating it. If you have several worker threads, one panicking doesn’t stop the others. The only way a worker’s panic reaches main is if you .unwrap() (or .expect()) the Err yourself — that re-panics, but now on the thread that called .join(), which is a choice you made, not something Rust forces on you.

Common mistakes

  • Forgetting move. If the closure uses an owned value from outside, you almost always need thread::spawn(move || ...), or the compiler complains the borrow might outlive the data.
  • Forgetting to .join(). If main ends before your threads finish, the program exits and cuts them off mid-work. Join the handles when you need the results (or need to guarantee the work completed).
  • Printing handle.join() directly. It’s a Result, not the value inside — you need .unwrap() or a match/if let to get at T.
  • .join().unwrap() on a thread that might legitimately panic. That turns “one worker failed” into “the thread that’s waiting for it also panics.” Match on the Err explicitly if a failure shouldn’t be fatal.
  • Spawning a thread per tiny unit of work. Each OS thread has real overhead (its own stack, kernel bookkeeping). For lots of small, short-lived tasks, reach for a thread pool (e.g. the rayon crate) instead of spawning thousands of threads.

More examples

Resizing a batch of images in parallel

Resizing a batch of images is naturally parallel — spawn one thread per image and collect every resized name once all threads finish.

use std::thread;

fn resize(name: &str) -> String {
    format!("{name}-resized")
}

fn main() {
    let images = vec!["cat.png", "dog.png", "bird.png"];
    let mut handles = vec![];

    for image in images {
        handles.push(thread::spawn(move || resize(image)));
    }

    let mut results: Vec<String> = handles
        .into_iter()
        .map(|h| h.join().unwrap())
        .collect();

    results.sort(); // sort so the printed order doesn't depend on thread timing
    println!("{:?}", results);
}

Summing a huge dataset with a map-reduce split

Summing a huge dataset is faster split across threads — divide the numbers into chunks, sum each chunk on its own thread, then add the partial totals together.

use std::thread;

fn main() {
    let numbers: Vec<i32> = (1..=100).collect();
    let chunk_size = 25;
    let mut handles = vec![];

    for chunk in numbers.chunks(chunk_size) {
        let chunk = chunk.to_vec(); // owned copy so the thread doesn't borrow `numbers`
        handles.push(thread::spawn(move || chunk.iter().sum::<i32>()));
    }

    let total: i32 = handles.into_iter().map(|h| h.join().unwrap()).sum();
    println!("total: {total}"); // 5050
}

Health-checking several servers at once

Health-checking five servers one at a time wastes most of the time waiting — spawning one thread per server runs all the checks concurrently instead.

use std::thread;

fn check_server(name: &str) -> (String, bool) {
    // pretend this pings the server
    (name.to_string(), name != "db-3")
}

fn main() {
    let servers = vec!["web-1", "web-2", "db-3", "cache-4"];
    let mut handles = vec![];

    for server in servers {
        handles.push(thread::spawn(move || check_server(server)));
    }

    let mut results: Vec<(String, bool)> = handles
        .into_iter()
        .map(|h| h.join().unwrap())
        .collect();

    results.sort();
    for (name, healthy) in &results {
        println!("{name}: {}", if *healthy { "up" } else { "down" });
    }
}

A batch job that survives one bad input

A batch of independent jobs shouldn’t all fail just because one input is bad — join each handle individually so one panicking thread doesn’t stop you from checking the rest.

use std::thread;

fn process(id: i32) -> i32 {
    if id == 3 {
        panic!("bad input at id {id}");
    }
    id * 10
}

fn main() {
    let mut handles = vec![];
    for id in 1..=5 {
        handles.push(thread::spawn(move || process(id)));
    }

    let mut succeeded = 0;
    let mut failed = 0;
    for h in handles {
        match h.join() {
            Ok(value) => {
                succeeded += 1;
                println!("job finished: {value}");
            }
            Err(_) => {
                failed += 1;
                println!("a job panicked");
            }
        }
    }

    println!("{succeeded} succeeded, {failed} failed"); // 4 succeeded, 1 failed
}

Your turn

This program sums a vector on a worker thread and prints the total. It has two bugs — the closure can’t see numbers, and the final println! doesn’t do what it looks like.

use std::thread;

fn main() {
    let numbers = vec![10, 20, 30, 40];

    let handle = thread::spawn(|| {
        let total: i32 = numbers.iter().sum();
        total
    });

    let total = handle.join();
    println!("total = {total}");
}
Show solution

Two separate problems:

  1. The closure borrows numbers, but thread::spawn needs a 'static closure — add move so the thread owns numbers outright.
  2. handle.join() returns a Result<i32, _>, not an i32. Result doesn’t implement Display, so println!("{total}") fails to compile. Call .unwrap() to get the actual number out.
use std::thread;

fn main() {
    let numbers = vec![10, 20, 30, 40];

    let handle = thread::spawn(move || {   // <-- move: the thread now owns `numbers`
        let total: i32 = numbers.iter().sum();
        total
    });

    let total = handle.join().unwrap();    // <-- unwrap the Result to get the i32
    println!("total = {total}");
}

Now it compiles and prints total = 100.

Quick check

Remember this

  • thread::spawn(move || { ... }) starts a new OS thread immediately; move is needed because the closure might outlive the caller’s stack frame.
  • .join() blocks until the thread finishes and returns a Result<T, Box<dyn Any + Send>>Ok(value) is the closure’s return value.
  • A panic inside a spawned thread does not crash the whole program — only that thread unwinds, and .join() reports it as Err.
  • .join().unwrap() turns a worker’s panic into a panic on the thread that called it — match the Err explicitly if you want to contain the failure.
  • Spawning a thread per tiny task is expensive; for lots of small jobs, use a thread pool (e.g. rayon) instead.

Go deeper

Next:

Channels (mpsc)

Intermediate · Runtime & ecosystem

What & why

Once you have more than one thread, they usually need to talk. Rust’s guiding motto for this is: “do not communicate by sharing memory; share memory by communicating.” Instead of several threads reaching into the same variable, they pass ownership of values down a pipe. std::sync::mpsc (multi-producer, single-consumer) gives you exactly that pipe: a Sender you can clone for as many producer threads as you want, and one Receiver that reads whatever arrives.

The idea, slowly

A channel is a pipe with two ends

mpsc::channel() returns a (Sender, Receiver) pair — conventionally named tx (transmitter) and rx (receiver).

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        for word in ["hello", "from", "the", "worker"] {
            tx.send(word.to_string()).unwrap(); // push a message into the pipe
        }
        // when `tx` is dropped here (closure ends), the channel starts closing
    });

    for received in rx {
        println!("main got: {received}");
    }
}

Press Run. The worker thread pushes four words down the pipe; main reads them one at a time. We moved tx into the thread with move, so ownership of the sending end is unambiguous — there’s no shared mutable anything here, just values traveling through a pipe.

.send() moves ownership, it doesn’t copy

Sender::send takes the value by ownership: fn send(&self, t: T) -> Result<(), SendError<T>>. Once you send a value, it’s gone from your side — the receiving thread owns it now.

use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();
    let msg = String::from("hello");

    tx.send(msg).unwrap();
    println!("{msg}"); // ERROR: `msg` was moved into `send`
}

msg moved into send, so using it afterward is the same “use after move” error you’d get anywhere else in Rust. If you genuinely need to keep a copy on the sending side, clone it before sending: tx.send(msg.clone()). This is exactly the behavior you want for concurrency — the receiver gets a value it fully owns, with no risk of the sender also touching it at the same time.

Many producers, one receiver: cloning the Sender

The “multi-producer” half of mpsc means Sender implements Clone. Each clone is a separate handle to the same underlying pipe, so several threads can all send into one Receiver.

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    for id in 0..3 {
        let tx = tx.clone();               // each thread gets its own handle to the pipe
        thread::spawn(move || {
            tx.send(format!("worker {id} says hi")).unwrap();
        });
    }
    drop(tx); // the original handle must go too, or the channel never looks "empty"

    for msg in rx {
        println!("{msg}");
    }
}

tx.clone() is cheap — like Arc, it’s bumping a reference count under the hood, not duplicating the channel. Notice the drop(tx) after the loop: the original tx was never moved anywhere (only its clones were), so it’s still alive in main’s scope. If we left it there, the receiving loop below would wait forever for a message that’s never coming — see the next section for why.

The receiver as an iterator: for msg in rx

Receiver<T> implements IntoIterator. A for msg in rx loop blocks on each iteration until a message arrives, and the loop ends automatically once every Sender (the original and all its clones) has been dropped. That’s the channel’s built-in “we’re done” signal — no manual “stop” message required.

This cuts both ways:

  • Drop every sender (let them go out of scope, or drop() them explicitly) once you’re done producing, and the loop finishes cleanly.
  • Leave even one Sender clone alive somewhere — including an unused original sitting in main — and the loop waits forever, because as far as the channel knows, someone might still send.

Common mistakes

  • Using a value after sending it. .send() moves ownership; if you need the value afterward, clone it first.
  • Leaving a stray Sender alive. A forgotten clone (or the original tx, if you only ever used clones) keeps the channel open forever, so for msg in rx never returns.
  • Blindly .unwrap()-ing .send() in a long-running producer. send returns Err once the Receiver has been dropped — if the reader gave up early, unwrapping panics the sender. Handle the Result if that’s a real possibility.
  • Trying to clone the Receiver. Only Sender is Clone. With std::sync::mpsc there’s always exactly one consumer — if you need multiple readers, look at a different crate (or hand out work via a shared queue instead).
  • Assuming send() blocks. The default channel is unbounded — send returns immediately, buffering the value. If you want backpressure (the sender blocks when the buffer is full), use mpsc::sync_channel(bound) instead.

More examples

Streaming progress updates to a UI

A background job (a file download, a big export) wants to report how far along it is, while the main thread just prints whatever comes in — this is the classic producer/consumer split.

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        for pct in [25, 50, 75, 100] {
            tx.send(pct).unwrap();
        }
    });

    for pct in rx {
        println!("progress: {pct}%");
    }
    println!("done!");
}

Many producers, one collector

Say three players finish a game on their own threads and need to report a score back to a single scoreboard — clone tx once per thread, and rx sees everything as it arrives.

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    for player in ["alice", "bob", "carol"] {
        let tx = tx.clone();
        thread::spawn(move || {
            tx.send((player, player.len() as u32 * 10)).unwrap();
        });
    }
    drop(tx);

    let mut total = 0;
    for (name, score) in rx {
        println!("{name} scored {score}");
        total += score;
    }
    println!("total: {total}");
}

A work queue: send tasks, receive results back

This is the shape behind most “worker pool” designs — one channel carries jobs in, a worker does the (pretend) expensive work, and a second channel carries results back out.

use std::sync::mpsc;
use std::thread;

fn main() {
    let (task_tx, task_rx) = mpsc::channel::<u32>();
    let (result_tx, result_rx) = mpsc::channel();

    thread::spawn(move || {
        for n in task_rx {
            result_tx.send(n * n).unwrap(); // pretend this is expensive work
        }
    });

    for n in 1..=5 {
        task_tx.send(n).unwrap();
    }
    drop(task_tx); // tell the worker there's no more work coming

    for squared in result_rx {
        println!("squared: {squared}");
    }
}

Polling without blocking, using try_recv()

Sometimes you can’t afford to sit and block on rx.recv() — a game loop or UI loop needs to check “did anything arrive?” and keep moving either way. try_recv() never blocks: it returns immediately with whatever it finds.

use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        thread::sleep(Duration::from_millis(50));
        tx.send("finally ready").unwrap();
    });

    loop {
        match rx.try_recv() {
            Ok(msg) => {
                println!("got: {msg}");
                break;
            }
            Err(mpsc::TryRecvError::Empty) => {
                println!("...still waiting, doing other work");
                thread::sleep(Duration::from_millis(10));
            }
            Err(mpsc::TryRecvError::Disconnected) => break,
        }
    }
}

Your turn

This program spawns three worker threads that each send a message, then reads them all in main. It doesn’t compile.

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    for id in 0..3 {
        thread::spawn(move || {
            tx.send(format!("message from worker {id}")).unwrap();
        });
    }

    for msg in rx {
        println!("{msg}");
    }
}
Show solution

The first loop iteration moves tx into its closure (move ||). By the second iteration, tx has already been moved away — there’s nothing left to give the next thread. The compiler reports use of moved value: tx.

Fix it by cloning tx inside the loop, so each thread gets its own handle, and drop the original once you’re done handing out clones:

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    for id in 0..3 {
        let tx = tx.clone();          // give this thread its own handle
        thread::spawn(move || {
            tx.send(format!("message from worker {id}")).unwrap();
        });
    }
    drop(tx); // main's original copy must go too, or the channel never closes

    for msg in rx {
        println!("{msg}");
    }
}

Now every thread sends through its own clone, the original is dropped once handed out, and once all three worker threads finish (dropping their clones too), the for msg in rx loop ends on its own.

Quick check

Remember this

  • mpsc = multi-producer, single-consumer: clone Sender for more producer threads, but there’s only ever one Receiver.
  • .send(value) moves value across the channel — the receiving thread gets ownership, not a reference.
  • for msg in rx blocks and yields messages until every Sender (original plus clones) has been dropped, then the loop ends.
  • A stray Sender clone left alive anywhere keeps the channel open forever — drop what you don’t need.
  • .send() returns a Result that becomes Err once the Receiver is gone — don’t blindly .unwrap() it in a long-running producer.

Go deeper

Next:

Shared state: Arc and Mutex

Advanced · Runtime & ecosystem

What & why

Channels are great when work flows in one direction, but sometimes several threads genuinely need to read and write the same piece of data — a shared counter, a cache, a connection pool. Rust’s standard answer is Arc<Mutex<T>>: Arc lets multiple threads co-own the same value, and Mutex makes sure only one of them touches it at a time. Together they let you share mutable state without a data race — and the compiler won’t even let you try it any other way.

The idea, slowly

Why plain shared state doesn’t compile

If you’ve used Rc<RefCell<T>> for shared mutable state in single-threaded code, the instinct is to reach for it here too. It won’t compile across threads:

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

fn main() {
    let counter = Rc::new(RefCell::new(0));
    let counter2 = Rc::clone(&counter);

    thread::spawn(move || {
        *counter2.borrow_mut() += 1;   // ERROR: `Rc` cannot be sent between threads safely
    });
}

Rc’s reference count isn’t updated atomically — two threads bumping it at the same instant could corrupt it. The compiler marks Rc (and RefCell) as not safe to send across threads, so this fails at compile time instead of racing at runtime. You need their thread-safe siblings: Arc instead of Rc, Mutex instead of RefCell.

Arc: shared ownership, and cloning it is cheap

Arc<T> stands for Atomically Reference Counted. Cloning an Arc doesn’t copy the data inside — it bumps a counter and hands back another pointer to the same value:

use std::sync::Arc;

fn main() {
    let data = Arc::new(String::from("shared"));
    println!("count after creation: {}", Arc::strong_count(&data)); // 1

    let clone1 = Arc::clone(&data);
    let clone2 = Arc::clone(&data);
    println!("count after two clones: {}", Arc::strong_count(&data)); // 3

    drop(clone1);
    println!("count after dropping one: {}", Arc::strong_count(&data)); // 2

    println!("all three point at the same string: {clone2}");
}

Arc::clone(&data) is the idiomatic way to write it (rather than data.clone()) — it makes it obvious at a glance that you’re bumping a refcount, not doing a deep, expensive copy. The underlying String is only ever freed once the last Arc pointing at it is dropped.

Mutex: one at a time, enforced by a lock

Mutex<T> wraps a value and only lets one thread touch it at a time. .lock() blocks until the lock is free, then hands back a MutexGuard<T> — a smart pointer that derefs to &mut T and automatically releases the lock when it’s dropped, no manual unlock call needed:

use std::sync::Mutex;

fn main() {
    let count = Mutex::new(0);

    {
        let mut guard = count.lock().unwrap(); // blocks until the lock is free
        *guard += 1;
    } // <- guard drops here, lock releases automatically

    println!("count = {}", *count.lock().unwrap());
}

.lock() actually returns Result<MutexGuard<T>, PoisonError<...>>, not the guard directly — the Err case only happens if some other thread panicked while holding the lock (Rust calls the mutex “poisoned” after that, as a warning that the data might be in a weird half-updated state). .unwrap() is the common shortcut when you’re confident that won’t happen; real long-running services sometimes recover from a poisoned lock instead of panicking too.

Combining them: Arc<Mutex<T>> across threads

Put the two together and you get real shared mutable state, safely:

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0)); // shared, lockable number
    let mut handles = vec![];

    for _ in 0..5 {
        let counter = Arc::clone(&counter); // clone the handle, not the number
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap(); // lock it — others wait
            *num += 1;                              // safely add 1
        }); // lock releases here, when `num` goes out of scope
        handles.push(handle);
    }

    for h in handles {
        h.join().unwrap();
    }

    println!("final count: {}", *counter.lock().unwrap()); // 5
}

Each thread gets its own Arc handle (cheap clone) pointing at the same Mutex, locks it just long enough to increment the number, and releases it immediately when the guard drops at the end of the closure. Five threads, zero data races, guaranteed final count of 5.

Deadlock risk: locking two mutexes out of order

A Mutex only guarantees one thread at a time — it says nothing about order. If two threads need to lock two different mutexes, and they lock them in different orders, you can get a deadlock: each thread holds one lock and waits forever for the other.

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let a = Arc::new(Mutex::new(0));
    let b = Arc::new(Mutex::new(0));

    let (a1, b1) = (Arc::clone(&a), Arc::clone(&b));
    let t1 = thread::spawn(move || {
        let _guard_a = a1.lock().unwrap();                          // t1 grabs `a` first
        std::thread::sleep(std::time::Duration::from_millis(50));
        let _guard_b = b1.lock().unwrap();                          // ...then wants `b`
    });

    let (a2, b2) = (Arc::clone(&a), Arc::clone(&b));
    let t2 = thread::spawn(move || {
        let _guard_b = b2.lock().unwrap();                          // t2 grabs `b` first
        std::thread::sleep(std::time::Duration::from_millis(50));
        let _guard_a = a2.lock().unwrap();                          // ...then wants `a`
    });

    t1.join().unwrap();
    t2.join().unwrap();
    // t1 ends up holding `a`, waiting for `b`.
    // t2 ends up holding `b`, waiting for `a`.
    // Neither can proceed. This hangs forever.
}

(This example is deliberately not runnable here — it would hang the page.) t1 locks a then reaches for b; t2 locks b then reaches for a. If they interleave unluckily, each ends up waiting on a lock the other is holding, and neither ever lets go. Rust’s compiler can’t catch this for you — deadlocks are a runtime problem, not a type error. The fix is a discipline, not a language feature: always lock mutexes in the same global order everywhere in your code. If every code path locks a before b, this interleaving simply can’t happen.

Common mistakes

  • Locking two mutexes in inconsistent order across different code paths. The classic deadlock. Always lock in the same global order.
  • Holding a MutexGuard longer than necessary — across a slow computation or (in async code) an .await — blocks every other thread waiting on that lock. Keep locked sections short.
  • Reaching for Rc<RefCell<T>> instead of Arc<Mutex<T>> across threads. Rc and RefCell aren’t Send/Sync; the compiler refuses to let them cross a thread boundary.
  • Locking the same Mutex twice on one thread (e.g. a helper function locks it again while you’re still holding the outer guard) — you deadlock against yourself, waiting for a lock only you hold.
  • Forgetting .lock() returns a Result. .unwrap() is fine for lessons and quick scripts; production code sometimes needs to handle a poisoned mutex instead of panicking.

More examples

Collecting log lines from parallel workers

A batch job that fans out across worker threads still needs one combined log — a Mutex-protected Vec lets every worker append its own line without stepping on the others.

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let log = Arc::new(Mutex::new(Vec::new()));
    let mut handles = vec![];

    for worker_id in 0..4 {
        let log = Arc::clone(&log);
        handles.push(thread::spawn(move || {
            let line = format!("worker {worker_id} finished its batch");
            log.lock().unwrap().push(line);
        }));
    }

    for h in handles {
        h.join().unwrap();
    }

    let log = log.lock().unwrap();
    println!("collected {} log lines:", log.len()); // 4
    for line in log.iter() {
        println!("{line}"); // order varies between runs — each worker finishes independently
    }
}

Tracking a game’s high score across players

Several players’ threads all race to update the same leaderboard entry — locking the mutex before comparing keeps the “is this actually higher?” check and the update from being interrupted mid-way.

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let high_score = Arc::new(Mutex::new(0));
    let scores = [42, 108, 77, 95];
    let mut handles = vec![];

    for score in scores {
        let high_score = Arc::clone(&high_score);
        handles.push(thread::spawn(move || {
            let mut best = high_score.lock().unwrap();
            if score > *best {
                *best = score;
            }
        }));
    }

    for h in handles {
        h.join().unwrap();
    }

    println!("high score: {}", *high_score.lock().unwrap()); // 108
}

A memoization cache shared by worker threads

An expensive lookup only needs to run once per key — a HashMap behind a Mutex lets every thread check (and fill) the same cache instead of each keeping its own.

use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let cache: Arc<Mutex<HashMap<u32, u32>>> = Arc::new(Mutex::new(HashMap::new()));
    let mut handles = vec![];

    for id in [1, 2, 1, 3, 2] {
        let cache = Arc::clone(&cache);
        handles.push(thread::spawn(move || {
            let mut cache = cache.lock().unwrap();
            cache.entry(id).or_insert_with(|| id * 100);
        }));
    }

    for h in handles {
        h.join().unwrap();
    }

    let cache = cache.lock().unwrap();
    println!("cached {} unique entries", cache.len()); // 3
}

A shared bank balance receiving concurrent deposits

Every deposit thread needs to add its amount to the same balance — the lock makes each addition atomic, so five concurrent deposits add up exactly, never lose one to a race.

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let balance = Arc::new(Mutex::new(0));
    let deposits = [50, 20, 100, 30, 75];
    let mut handles = vec![];

    for amount in deposits {
        let balance = Arc::clone(&balance);
        handles.push(thread::spawn(move || {
            let mut balance = balance.lock().unwrap();
            *balance += amount;
        }));
    }

    for h in handles {
        h.join().unwrap();
    }

    println!("final balance: {}", *balance.lock().unwrap()); // 275
}

Your turn

This program tries to have five threads increment a shared counter. It doesn’t compile.

use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Mutex::new(0);
    let mut handles = vec![];

    for _ in 0..5 {
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();
            *num += 1;
        });
        handles.push(handle);
    }

    for h in handles {
        h.join().unwrap();
    }

    println!("final count: {}", *counter.lock().unwrap());
}
Show solution

The first loop iteration’s move closure takes ownership of counter outright. There’s only one counter — by the second iteration, it’s already been moved away, so the compiler reports use of moved value: counter. A bare Mutex has no way to be shared between threads; it can only be owned by one place.

The fix is Arc<Mutex<T>>: wrap the mutex in an Arc, and clone the Arc (not the mutex) for each thread.

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0)); // <-- wrap in Arc so it can be shared
    let mut handles = vec![];

    for _ in 0..5 {
        let counter = Arc::clone(&counter); // <-- clone the handle for this thread
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();
            *num += 1;
        });
        handles.push(handle);
    }

    for h in handles {
        h.join().unwrap();
    }

    println!("final count: {}", *counter.lock().unwrap()); // 5
}

Arc::clone bumps a refcount instead of consuming the only copy, so every thread — and main afterward — gets its own handle to the same Mutex.

Quick check

Remember this

  • Arc<T> is Rc<T>’s thread-safe sibling: Arc::clone bumps an atomic refcount (cheap) — it doesn’t copy the data.
  • mutex.lock() blocks until the lock is free, then hands back a MutexGuard that derefs to &mut T.
  • The MutexGuard unlocks automatically when it’s dropped (goes out of scope) — there’s no manual unlock call.
  • Arc<Mutex<T>> together give thread-safe shared ownership plus exclusive access; a bare shared mutable reference across threads simply won’t compile.
  • Locking two mutexes in different orders on different threads is a classic deadlock — always lock in the same global order.

Go deeper

Next:

Async and await basics

Intermediate · Runtime & ecosystem

What & why

Most of a slow program’s time isn’t spent computing — it’s spent waiting: for a network reply, a database, a file, a timer. A thread that blocks on each of those wastes the entire thread doing nothing. Async is Rust’s way to describe “work that involves waiting” so that waiting never ties up a whole OS thread. This lesson is only about the language-level mental model — what async fn and .await actually do, mechanically, when you write them. It’s the trickiest beginner topic in Rust, so we go slowly, and we deliberately stop short of running real async programs — that’s the next lesson, once you have a runtime (Tokio) to actually execute this stuff.

The idea, slowly

An async fn is a recipe card, not a cooked meal

Imagine you hand someone a recipe card instead of a meal. The card describes every step — chop this, boil that, wait for the oven — but handing it over doesn’t cook anything. Nobody’s touched a stove. That card just sits there until someone actually starts following it.

That’s exactly what happens when you call an async fn. Marking a function async changes what “calling” it means: instead of running the body, Rust hands you back a Future — a value describing the work to be done, completely inert until something drives it forward.

async fn fetch_value() -> String {
    println!("fetch_value: actually running now");
    "ready".to_string()
}

fn main() {
    let future = fetch_value();               // the recipe card, not the meal
    println!("called fetch_value — but did its println fire?");
    drop(future); // thrown away, unstarted — its body never ran a single line
}

Run this and only the second println! shows up. The first one — inside fetch_value — never fires, because fetch_value’s body never executed. Calling the function just built a Future object and handed it to you; nothing inside it ran.

What the compiler is thinking: when it sees async fn, it doesn’t compile a function that runs top to bottom like normal. It rewrites the body into a state machine — a struct that remembers “which step am I on” — and implements a trait for it, roughly:

#![allow(unused)]
fn main() {
trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
}

poll is “take one step, or as many steps as you can without waiting.” It returns Poll::Ready(value) if the work finished, or Poll::Pending if it hit a point where it has to wait for something. Calling an async fn just builds one of these state machines sitting at step zero. Nobody has called poll on it yet — so nothing has happened.

.await follows the recipe — and steps aside instead of standing frozen

Handing someone a recipe card is useless without someone actually cooking. .await is what turns “here’s a future” into “actually do this, and here’s the value when it’s done.” But it does something smarter than just blocking until finished: at every step that requires waiting — water boiling, an oven preheating — .await steps aside and lets other work happen in the meantime, then comes back the instant that step is ready.

Concretely: .await repeatedly calls poll on the future. Every time poll returns Poll::Pending (this step needs to wait), control returns to whatever is running the show instead of the CPU sitting there spinning or the thread blocking. Other tasks get a turn. When the thing being waited on becomes ready, this future gets polled again and picks up exactly where it left off.

This is the core difference from a normal blocking call. std::thread::sleep(dur) freezes the entire OS thread — nothing else on that thread can run until it wakes up. An .await on an async sleep, by contrast, gives up its turn so the thread can go do other useful work, then resumes later. Same waiting, wildly different cost.

.await is also restricted: you can only write it inside async code (an async fn or an async block), because only that code has been rewritten into a pollable state machine that knows how to pause and resume.

async fn fetch_value() -> String {
    "ready".to_string()
}

async fn use_it() {
    let value = fetch_value().await;  // legal: .await inside an async fn
    println!("got: {value}");
}

fn main() {
    let _future = use_it();  // still just a recipe card — nothing ran
    println!("main finished, but use_it's body never executed");
}

Notice use_it itself is async — it can .await inside itself, but calling use_it() from main (a normal, non-async function) still only builds another future. main never .awaits it, so, again, nothing inside use_it (or the fetch_value it awaits) ever runs.

Nothing runs on its own — something has to drive the top future

Here’s the catch that trips up every beginner: .await only works inside async code. But main is a normal function by default. So who follows the very first recipe card? Something has to repeatedly call poll on your top-level future until it’s done — parking it while it waits, waking it back up when it’s ready. That something is called an executor (or runtime).

Rust the language gives you async and .await — the syntax for writing and following recipe cards. It ships no executor. Without one, an async program compiles perfectly and does absolutely nothing at runtime, because nothing is ever calling poll. You have to bring your own kitchen coordinator. The overwhelmingly popular choice in the ecosystem is Tokio, which is exactly what the next lesson is about.

If you write a future and just let it drop without ever awaiting or handing it to a runtime, the compiler tries to warn you:

async fn fetch_value() -> String {
    "ready".to_string()
}

fn main() {
    fetch_value(); // statement, result discarded
    println!("done");
}

Run this and the compiler prints warning: unused implementer of \Future` that must be used, with a note: futures do nothing unless you .await or poll them`. That warning exists specifically because “I called an async function and assumed it ran” is the single most common async mistake.

Common mistakes

  • Assuming calling an async fn runs it. It only builds a Future. Nothing executes until something .awaits or polls it.
  • Using .await outside async code. .await is only legal inside an async fn or async block. Writing value.await in a plain fn main() is a compile error: `await` is only allowed inside `async` functions and blocks (error E0728).
  • Letting a future drop unused. A future you never .await or spawn never runs, even if you called the function that created it. The compiler’s unused implementer of Future warning is your safety net — don’t ignore it.
  • Treating .await as “just wait, like a blocking call.” It behaves very differently: while waiting, it yields control so other work can run on the same thread. A real blocking call (std::thread::sleep, synchronous file I/O) has no such courtesy — it freezes the thread.
  • Expecting async to speed up CPU-bound work. Async concurrency comes from not blocking while waiting on I/O. If there’s no waiting — just a tight computation — async adds bookkeeping overhead for no benefit. Plain threads (or nothing at all) are usually the right tool there.

More examples

Requesting a weather forecast

Calling get_forecast doesn’t hit the network — it builds a Future describing the call, and dropping that future without awaiting it means the request never goes out.

async fn get_forecast(city: &str) -> String {
    println!("get_forecast: calling the weather API for {city}");
    format!("sunny in {city}")
}

fn main() {
    let forecast = get_forecast("Dhaka");
    println!("holding a Future — the API call above hasn't happened yet");
    drop(forecast);
}

Chaining a login into a dashboard load

One async fn can .await another to sequence steps — but that inner sequencing only ever plays out once something drives the outer future, which main never does here.

async fn log_in(user: &str) -> bool {
    println!("log_in: checking credentials for {user}");
    true
}

async fn load_dashboard(user: &str) -> String {
    let ok = log_in(user).await;
    if ok {
        format!("dashboard for {user}")
    } else {
        String::from("access denied")
    }
}

fn main() {
    let dashboard = load_dashboard("shaon");
    println!("built the login+dashboard future, but log_in's println never ran");
    drop(dashboard);
}

Queuing ad-hoc work with an async block

You don’t need a named async fn for a one-off task — an async { ... } block is a future too, built and left unstarted exactly the same way.

fn main() {
    let task = async {
        println!("task: uploading screenshot");
        "upload complete"
    };
    println!("task queued — but did 'uploading screenshot' print?");
    drop(task);
}

Firing a metrics ping and forgetting to await it

Calling ping_metrics(...) as a bare statement looks like a fire-and-forget call, but it’s really an unused Future — the compiler’s warning is the only thing standing between this and a metrics ping that silently never happens.

async fn ping_metrics(event: &str) {
    println!("ping_metrics: recording {event}");
}

fn main() {
    ping_metrics("checkout_completed"); // looks fire-and-forget, but it isn't
    println!("checkout finished — did the metrics ping actually fire?");
}

Your turn

This is supposed to fetch a value and print it. It has two separate compile errors. Find both before checking the solution.

async fn fetch() -> String {
    String::from("hello")
}

fn main() {
    let value = fetch();          // problem 1
    println!("{}", value.await);  // problem 2
}
Show solution

Problem 1: fetch() only creates a future — calling it does not run the function body. Problem 2: .await is used inside main, but main is an ordinary (non-async) function, and .await is only legal inside async code. The compiler rejects this with error E0728: `await` is only allowed inside `async` functions and blocks.

The syntax fix is to move the .await into an async function:

async fn fetch() -> String {
    String::from("hello")
}

async fn run() {
    let value = fetch().await;   // now legal — inside async code
    println!("{value}");
}

fn main() {
    let _future = run();
    println!("main finished, but run()'s body never executed — nothing polled it");
}

This compiles cleanly — but notice it still doesn’t print "hello". run() builds a future, and main just drops it. Nobody ever called .await on run() itself, because main isn’t async and can’t be (not without help). Fixing the syntax errors got you a program that compiles and runs, but the async work genuinely never executes, because there’s still no executor driving it. That’s not a bug in this exercise — it’s the exact wall every async Rust program alone hits, and the next lesson (the Tokio runtime) is precisely what gets you past it.

Quick check

Remember this

  • Calling an async fn does not run its body — it immediately returns a Future, a paused state machine sitting at step zero.
  • .await drives a future forward by polling it, and only works inside async code.
  • While a future is waiting on something, .await yields control back instead of blocking the OS thread — that’s the entire efficiency win over a blocking call.
  • async/.await are just language syntax. Rust ships no executor — you need a runtime (like Tokio) to actually drive a future to completion.
  • A future you never .await or spawn never runs, even though the compiler happily let you create it — watch for the unused implementer of Future warning.

Go deeper

  • Async book — Official async learning material.

Next:

The Tokio runtime and tasks

Intermediate · Runtime & ecosystem

What & why

The previous lesson ended on a cliffhanger: async/.await compile and run fine, but nothing inside an async function ever executes unless something is actively driving it forward. That “something” is a runtime — and in the Rust ecosystem, that overwhelmingly means Tokio. This lesson is about actually using it: starting it with #[tokio::main], running background work with tokio::spawn, combining futures with join! and select!, and the single most common way beginners accidentally sabotage all of it — blocking the very thread async code depends on.

The idea, slowly

#[tokio::main]: sugar for “build a runtime, then run this on it”

main can’t normally be async — nothing would ever call .await on it. #[tokio::main] fixes that by rewriting your function into ordinary, synchronous code that builds a Tokio runtime and blocks on your async body. Roughly, this:

#[tokio::main]
async fn main() {
    println!("hello from async main");
}

expands to something like this:

fn main() {
    tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .unwrap()
        .block_on(async {
            println!("hello from async main");
        });
}

Builder::new_multi_thread() sets up a small pool of OS worker threads (by default, roughly one per CPU core). block_on is the executor loop from the previous lesson made concrete: it repeatedly polls your top-level future — parking when it’s waiting, resuming when it’s ready — until it finishes, and only then does main return. This needs the tokio crate and will not run on the Playground; in a real project:

cargo new asyncplay && cd asyncplay
cargo add tokio --features full
# paste your code into src/main.rs
cargo run

tokio::spawn: run a task concurrently in the background

tokio::spawn(future) is thread::spawn’s async cousin: hand it a future, and Tokio schedules it to run concurrently with everything else, immediately returning a JoinHandle you can later .await to get its result. Unlike .awaiting a future directly (which runs it in place, blocking the rest of this task until it’s done), a spawned task runs independently — the caller keeps going right away.

#[tokio::main]
async fn main() {
    let handle = tokio::spawn(async {
        "task result".to_string()
    });

    println!("spawned — the task is already running concurrently");
    let result = handle.await.unwrap(); // wait for it, and get its value
    println!("got: {result}");
}

Here’s the part that surprises almost everyone the first time: a spawned future must be 'static (own everything it touches, borrowing nothing with a shorter lifetime) and Send (safe to hand across threads). Why so strict? Tokio’s default scheduler can move a task between worker threads at any .await point, and the task might keep running long after the function that spawned it has returned. Tokio genuinely cannot guarantee a borrowed local will still be alive, or that a non-thread-safe type won’t get torn across threads — so it simply refuses to compile code that risks it.

#[tokio::main]
async fn main() {
    let name = String::from("rustacean");
    let handle = tokio::spawn(async {
        println!("hello, {name}"); // borrows `name` from main — not 'static
    });
    handle.await.unwrap();
}

This fails with error E0373: “async block may outlive the current function, but it borrows name, which is owned by the current function.” The fix — and the almost-always-correct instinct — is move, so the task owns its own copy instead of borrowing:

#[tokio::main]
async fn main() {
    let name = String::from("rustacean");
    let handle = tokio::spawn(async move {
        println!("hello, {name}"); // now owned by the task
    });
    handle.await.unwrap();
}

tokio::join!: run several futures concurrently, wait for all of them

join!(a, b, c) starts all its futures and drives them concurrently — while one is waiting on I/O, another can make progress — and only returns once every one of them has finished, as a tuple of their results, in the order you wrote them.

async fn fetch(name: &str) -> String {
    // pretend this is a network call
    format!("{name}-done")
}

#[tokio::main]
async fn main() {
    // both run concurrently; if each "takes" 50ms, this takes ~50ms total, not 100ms
    let (a, b) = tokio::join!(fetch("one"), fetch("two"));
    println!("{a} {b}");
}

Reach for join! whenever you need several independent results before you can continue, and there’s no reason to fetch them one after another.

tokio::select!: race several futures, take whichever finishes first

select! is a different shape entirely: it polls several branches concurrently, and the moment any one of them completes, its arm runs — and every other branch is immediately dropped, mid-flight, uncompleted. It doesn’t wait for the rest; it doesn’t finish them later. They’re simply cancelled.

use std::time::Duration;

async fn fetch(name: &str) -> String {
    format!("{name}-done")
}

#[tokio::main]
async fn main() {
    tokio::select! {
        result = fetch("fast") => {
            println!("fast finished first: {result}");
        }
        _ = tokio::time::sleep(Duration::from_secs(5)) => {
            println!("timed out after 5s");
        }
    }
}

This is exactly the shape a timeout takes: race the real work against a timer, and whichever resolves first wins — the loser is simply abandoned. Use join! when you need all the results; use select! when you need whichever comes first and the rest becomes irrelevant.

Blocking calls stall the whole worker thread

Tokio’s concurrency trick only works because tasks cooperate: each one runs until it hits an .await on something not yet ready, then politely steps aside so the worker thread can run other tasks. A genuinely blocking call — std::thread::sleep, a synchronous file read, a long CPU-bound loop with no .await in it — doesn’t step aside. It monopolizes the OS thread running it, and every other task scheduled on that same thread simply cannot make progress until the blocking call returns, no matter how “ready” they are.

This is measurable, not theoretical. Running two tasks on a single-worker-thread runtime:

use std::time::{Duration, Instant};

#[tokio::main(flavor = "current_thread")]
async fn main() {
    let start = Instant::now();

    let blocking = tokio::spawn(async move {
        println!("[{:?}] blocking task: starting", start.elapsed());
        std::thread::sleep(Duration::from_millis(200)); // freezes the whole worker thread
        println!("[{:?}] blocking task: done", start.elapsed());
    });

    let quick = tokio::spawn(async move {
        println!("[{:?}] quick task: ran", start.elapsed());
    });

    blocking.await.unwrap();
    quick.await.unwrap();
}

quick has nothing to wait on — it should print almost instantly. But it doesn’t print until after blocking finishes its 200ms sleep, because std::thread::sleep never yields: it just freezes the one worker thread both tasks share. Swap that line for tokio::time::sleep(Duration::from_millis(200)).await and quick prints within microseconds, because the async sleep yields the thread instead of hogging it.

The default multi-thread runtime has several worker threads, which hides this for a while — but the instant you have more blocking tasks than spare worker threads, the same freeze happens. When you genuinely need to run blocking or CPU-heavy work inside an async program, hand it to tokio::task::spawn_blocking, which moves the closure onto a separate thread pool set aside for exactly this, so it never stalls the async workers:

#![allow(unused)]
fn main() {
let result = tokio::task::spawn_blocking(|| {
    // real blocking work: heavy computation, a blocking library call, etc.
    std::thread::sleep(std::time::Duration::from_millis(10));
    42
}).await.unwrap();
}

Common mistakes

  • Forgetting move on a spawned task. tokio::spawn(async { ... }) that borrows a local fails with E0373 (“may outlive the current function”). Add move so the task owns what it needs — by far the most common first Tokio error.
  • Calling a blocking function inside an async task. std::thread::sleep, synchronous std::fs calls, or a tight CPU loop all freeze the worker thread they run on, stalling every other task scheduled there. Use tokio::time::sleep, tokio::fs, or spawn_blocking.
  • Assuming select!’s losing branches still finish. They don’t — they’re dropped mid-flight the instant another branch wins. If a branch has side effects partway through, those may never complete.
  • Reaching for join! when you actually wanted a race, or select! when you actually needed every result. They’re not interchangeable: join! always waits for all; select! always cancels the rest.
  • Missing the tokio dependency or #[tokio::main] entirely. Async code with no runtime either fails to compile (.await needs async context) or panics at runtime with something like “there is no reactor running” — a sure sign nothing is driving your futures.

More examples

Downloading a list of URLs concurrently

Downloading many URLs at once means spawning one task per URL instead of awaiting them one after another — collect the handles, then await each to gather every result.

#[tokio::main]
async fn main() {
    let urls = vec!["a.com", "b.com", "c.com"];
    let mut handles = vec![];

    for url in urls {
        handles.push(tokio::spawn(async move {
            format!("{url}: 200 OK")
        }));
    }

    for handle in handles {
        let result = handle.await.unwrap();
        println!("{result}");
    }
}

Counting completed jobs from concurrent tasks

Multiple spawned tasks that each need to report completion can share an Arc<Mutex<u32>> counter, exactly like threads do — the lock is just held very briefly, never across an .await.

use std::sync::{Arc, Mutex};

#[tokio::main]
async fn main() {
    let completed = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for job_id in 0..5 {
        let completed = Arc::clone(&completed);
        handles.push(tokio::spawn(async move {
            // pretend some async work happens here
            *completed.lock().unwrap() += 1;
            job_id
        }));
    }

    for handle in handles {
        handle.await.unwrap();
    }

    println!("jobs completed: {}", *completed.lock().unwrap()); // 5
}

Racing a primary and backup data source

When you have a primary and a backup service, race them with select! and use whichever answers first — the loser is simply abandoned.

use std::time::Duration;

async fn primary() -> String {
    tokio::time::sleep(Duration::from_millis(50)).await;
    "primary".to_string()
}

async fn backup() -> String {
    tokio::time::sleep(Duration::from_millis(200)).await;
    "backup".to_string()
}

#[tokio::main]
async fn main() {
    tokio::select! {
        result = primary() => println!("used {result}'s response"),
        result = backup() => println!("used {result}'s response"),
    }
}

Hashing a password without blocking other tasks

Password hashing is deliberately slow CPU work — running it directly in an async task would freeze every other task on that thread, so hand it to spawn_blocking instead.

#[tokio::main]
async fn main() {
    let password = "correct horse battery staple".to_string();

    let hashed = tokio::task::spawn_blocking(move || {
        // pretend this is an expensive, CPU-bound hash function
        let mut hash: u64 = 0;
        for byte in password.bytes() {
            hash = hash.wrapping_mul(31).wrapping_add(byte as u64);
        }
        hash
    })
    .await
    .unwrap();

    println!("hashed password: {hashed}");
}

Your turn

This spawns a task that greets a name captured from main. It refuses to compile.

#[tokio::main]
async fn main() {
    let name = String::from("rustacean");
    let handle = tokio::spawn(async {
        println!("hello, {name}");
    });
    handle.await.unwrap();
}
Show solution

The compiler rejects this with error E0373: the async block borrows name from main, but tokio::spawn requires everything the task touches to be 'static — fully owned, not borrowed from a stack frame that might disappear while the task is still running on some worker thread.

The fix is move, so the task takes ownership of its own copy of name instead of borrowing it:

#[tokio::main]
async fn main() {
    let name = String::from("rustacean");
    let handle = tokio::spawn(async move {
        println!("hello, {name}"); // owned by the task now
    });
    handle.await.unwrap();
}

async move captures name by value, so the task no longer depends on main’s stack frame at all — it can safely be scheduled on any worker thread, for as long as it needs.

Quick check

Remember this

  • #[tokio::main] async fn main() { ... } builds a Tokio runtime and blocks on your async body — it’s the executor from the previous lesson, made concrete.
  • tokio::spawn(future) runs a future concurrently in the background and requires it to be 'static + Send; forgetting move on captured locals is the classic first error (E0373).
  • tokio::join!(a, b) runs futures concurrently and waits for all of them; tokio::select! races futures and proceeds with whichever finishes first, dropping the rest.
  • Blocking calls (std::thread::sleep, synchronous file I/O, tight CPU loops) inside an async task freeze the whole worker thread they run on — every other task scheduled there stalls too.
  • For real blocking or CPU-heavy work, use tokio::task::spawn_blocking to move it off the async worker threads entirely.

Go deeper

  • Tokio docs — The de facto standard async runtime.

Next:

Declarative macros (macro_rules!)

Advanced · Runtime & ecosystem

What & why

A macro is code that writes code. You’ve used one since your very first program — println!. The ! is the giveaway: it’s not a function, it’s a macro, and macros work completely differently from functions. A function takes values and runs at runtime. A macro takes pieces of your source code and runs at compile time, splicing in new Rust before the compiler has even checked that any of it makes sense. macro_rules! is how you write your own. It works by pattern-matching the tokens you pass in — literally the words and symbols, not their meaning — and stamping out expanded code in their place.

The idea, slowly

It’s tokens in, code out — and it happens before type checking

Think of macro_rules! as a find-and-replace that runs on your source code, not on values. The compiler expands every macro call into plain Rust first, and only after that expansion does it start checking types. This has a real consequence: a macro can’t catch a type error, because it never looks at types — it only ever sees tokens (words, punctuation, brackets).

macro_rules! add {
    ($a:expr, $b:expr) => {
        $a + $b
    };
}

fn main() {
    let sum = add!(1, 2); // expands to: 1 + 2
    println!("{}", sum);

    // let bad = add!(1, "two"); // would expand to: 1 + "two"
    // That's a normal type error ("cannot add `{integer}` to `&str`"),
    // and it only shows up AFTER expansion — the macro itself has no idea
    // what a type even is. It just glued tokens together.
}

Uncomment that last block yourself and run it — the error you get is exactly the error you’d get from typing 1 + "two" by hand. That’s the proof: add! never validated anything, it just handed the compiler new source code.

Anatomy of a rule: pattern => expansion

Here’s the smallest useful macro:

macro_rules! say {
    ($msg:expr) => {
        println!("{}", $msg);
    };
}

fn main() {
    say!("hello from a macro");
    say!(1 + 2); // works with any expression, not just strings
}

Read it in two halves:

  • ($msg:expr) — the pattern. “Capture one expression from the call and name it $msg.”
  • everything after => — the expansion. The code to generate, with $msg substituted wherever it appears.

say!(1 + 2) expands, at compile time, into println!("{}", 1 + 2);. That’s the literal line the compiler goes on to build.

Fragment specifiers: what kind of code can $name capture

The :expr in $msg:expr is a fragment specifier — it tells the macro what shape of code is allowed to fill that slot. The ones you’ll meet constantly:

  • expr — an expression: 1 + 2, foo(), "hi"
  • ident — a bare name: counter, my_func
  • ty — a type: i32, String, Vec<u8>
  • block — a { ... } block

You can mix several in one pattern, with literal tokens (like :) required between them:

macro_rules! let_zero {
    ($name:ident : $ty:ty) => {
        let $name: $ty = Default::default();
    };
}

fn main() {
    let_zero!(count: i32);
    let_zero!(label: String);
    println!("{} {:?}", count, label);
}

$name only accepts a bare identifier (count, not 1 + 1), and $ty only accepts a type (i32, not a value). If you pass the wrong shape — say, an expression where the macro wants a type — you get a macro-matching error, not a type error, because matching happens before types exist to the compiler at all.

The tokens aren’t evaluated — they’re just copied

Because a macro receives code, not a value, using $x more than once in the expansion runs that code more than once:

macro_rules! twice {
    ($x:expr) => {
        { $x; $x }
    };
}

fn main() {
    let mut n = 0;
    twice!(n += 1); // expands to: { n += 1; n += 1; }
    println!("{}", n); // 2, not 1
}

If $x were an evaluated value being reused, n += 1 would only have run once. But twice! doesn’t get a value — it gets the tokens n += 1 and pastes them in twice. This is one of the most common surprises when you start writing macros: think “copy-paste of code,” never “capture of a result.”

Repetition: matching “as many as you like”

Real variadic macros — the kind that take any number of arguments, like vec! and println! — use repetition. Start with a macro that only handles one item:

macro_rules! one_item_vec {
    ($x:expr) => {{
        let mut v = Vec::new();
        v.push($x);
        v
    }};
}

fn main() {
    let v = one_item_vec!(42);
    println!("{:?}", v);
}

(The double braces {{ }} aren’t a typo — the outer pair is Rust’s block-expression syntax so the expansion is a single expression; the inner pair is the block’s contents.)

Now generalize it to any number of items with $( ... ),*:

macro_rules! my_vec {
    ( $( $x:expr ),* ) => {{
        let mut v = Vec::new();
        $( v.push($x); )*
        v
    }};
}

fn main() {
    let v = my_vec![1, 2, 3];
    println!("{:?}", v);

    let words = my_vec!["a", "b", "c"];
    println!("{:?}", words);
}

Read the two halves separately:

  • In the pattern, $( $x:expr ),* means “match zero or more expressions, separated by commas, and capture each one as $x.”
  • In the expansion, $( v.push($x); )* means “repeat this line once for every $x that got captured.”

That’s exactly how the real vec! macro in the standard library works. You’ve now built the same trick.

Common mistakes

  • Forgetting the !. vec[1, 2, 3] or println("hi") fail because the compiler goes looking for a function of that name and finds none — macros are always called with !.
  • Treating a macro argument like an already-evaluated value. As twice! showed, an argument used twice in the expansion runs twice. If it has a side effect (like n += 1 or a println!), that side effect repeats.
  • Repetition syntax that doesn’t match between pattern and expansion. In $( $x:expr ),* the separator (,) and the * in the pattern must line up with the $( ... )* in the expansion, or you get cryptic “no rules expected this token” errors.
  • Using the wrong fragment specifier. Passing 1 + 1 where a pattern expects :ident, or a bare name where it expects :ty, fails to match — even though both “look like code” to you, the macro matcher is strict about the shape.
  • Reaching for a macro when a function would do. Macros are harder to read and debug than functions, and tooling (autocomplete, go-to-definition) understands them less well. Use a macro only for what a function genuinely can’t do — a variable number of arguments, or generating new code like struct fields or match arms.

More examples

Picking the cheaper of two prices

A smaller! macro that expands to an if/else reads like a tiny inline function, but works on any comparable expression without committing to one type.

macro_rules! smaller {
    ($a:expr, $b:expr) => {
        if $a < $b { $a } else { $b }
    };
}

fn main() {
    let cheapest = smaller!(19.99, 24.50);
    println!("cheapest option: {cheapest}");
}

A tagged logging macro

Typing println!("[{}] {}", ...) at every call site gets old fast — a log! macro bakes the tag format in once and lets stringify! turn the bare level name into text.

macro_rules! log {
    ($level:ident, $msg:expr) => {
        println!("[{}] {}", stringify!($level), $msg);
    };
}

fn main() {
    log!(INFO, "server started");
    log!(ERROR, "connection refused");
}

Building a settings map with key => value pairs

Repetition isn’t just for lists — $( $key:expr => $value:expr ),* matches a whole comma-separated run of key-value pairs and inserts each one into a HashMap in one expansion.

use std::collections::HashMap;

macro_rules! settings {
    ($($key:expr => $value:expr),* $(,)?) => {{
        let mut map = HashMap::new();
        $( map.insert($key, $value); )*
        map
    }};
}

fn main() {
    let config = settings! {
        "theme" => "dark",
        "font_size" => "14",
    };
    println!("theme = {}", config["theme"]);
}

Generating getter methods for a struct

Macros aren’t limited to expressions — matching a struct’s field list and expanding an impl block generates a getter for every field without typing each one by hand.

struct Player {
    name: String,
    score: u32,
}

macro_rules! getters {
    ($struct_name:ident { $($field:ident: $ty:ty),* $(,)? }) => {
        impl $struct_name {
            $(
                fn $field(&self) -> &$ty {
                    &self.$field
                }
            )*
        }
    };
}

getters!(Player { name: String, score: u32 });

fn main() {
    let p = Player { name: "Ada".to_string(), score: 42 };
    println!("{} has {} points", p.name(), p.score());
}

Your turn

This macro is supposed to build a Vec from any number of items, the way vec! does — but it only has a rule for a single expression, and it’s being called with three. Fix it so my_vec![1, 2, 3] works.

macro_rules! my_vec {
    ($x:expr) => {{
        let mut v = Vec::new();
        v.push($x);
        v
    }};
}

fn main() {
    let v = my_vec![1, 2, 3];
    println!("{:?}", v);
}
Show solution

The pattern ($x:expr) only matches one expression. Calling my_vec![1, 2, 3] hands it three expressions separated by commas, which doesn’t fit that shape at all — the compiler says something like no rules expected the token ','. The fix is to add repetition, both in the pattern (to capture a comma-separated list) and in the expansion (to push each captured item):

macro_rules! my_vec {
    ( $( $x:expr ),* ) => {{
        let mut v = Vec::new();
        $( v.push($x); )*
        v
    }};
}

fn main() {
    let v = my_vec![1, 2, 3];
    println!("{:?}", v); // [1, 2, 3]

    let words = my_vec!["a", "b"];
    println!("{:?}", words); // ["a", "b"]
}

$( $x:expr ),* in the pattern says “zero or more expressions, comma-separated”; $( v.push($x); )* in the expansion says “repeat this line once per captured expression.” Now the macro accepts any number of items, just like vec!.

Quick check

Remember this

  • A macro operates on tokens (source code), not values — it fully expands before type checking runs.
  • macro_rules! name { (pattern) => { expansion }; } — the pattern captures pieces of the call, the expansion is the code to generate.
  • Fragment specifiers (expr, ident, ty, block, …) constrain what shape of code a $name is allowed to capture.
  • An argument used twice in the expansion runs twice — macros paste code, they don’t cache evaluated values.
  • $( ... ),* in a pattern captures a repeated, separated list; the matching $( ... )* in the expansion repeats generated code once per capture — that’s how vec!-style variadic macros work.

Go deeper

Next:

Procedural macros (derive macros you use)

Advanced · Runtime & ecosystem

What & why

You will probably never write a procedural macro in your first year of Rust — but you’ll use one in nearly every project you touch. #[derive(Debug)], #[derive(Serialize, Deserialize)] from serde, #[derive(Parser)] from clap, #[tokio::main] — all procedural macros. Unlike macro_rules!, which pattern-matches tokens, a procedural macro (“proc macro”) is an actual Rust program: it receives your code as a stream of tokens and runs arbitrary logic to decide what new code to generate. This lesson is about using them well — recognizing the three kinds, knowing why they live in their own crate, and knowing how to see what they actually generated.

The idea, slowly

The three kinds, and where you meet them

Derive macros attach with #[derive(...)] and add new code alongside your type, without touching the type itself:

#![allow(unused)]
fn main() {
use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Debug)]
struct User {
    id: u64,
    name: String,
}
}

#[derive(Serialize)] doesn’t change the User struct one bit — it generates a separate impl Serialize for User { ... } block right next to it, one that knows how to walk id and name and turn them into JSON (or whatever format you’re serializing to). #[derive(Debug)] works the same way: it generates an impl Debug for User that knows how to print your fields. You get the impl for free; your struct definition stays exactly as you wrote it.

Attribute macros attach above an item too, but — unlike derive — they can rewrite the whole item, not just add something beside it. The one you’ve almost certainly used is #[tokio::main]:

#[tokio::main]
async fn main() {
    println!("hello from an async main");
}

Rust’s real main can never be async fn on its own — something has to create an async runtime and drive that future to completion. #[tokio::main] is what does it: it takes your async fn main, and generates roughly this in its place:

fn main() {
    tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .unwrap()
        .block_on(async {
            println!("hello from an async main");
        })
}

Your async fn main never actually exists in the compiled program — the attribute macro replaced it with a plain fn main that spins up a runtime and runs your code inside it.

Function-like macros look exactly like a macro_rules! call at the call site — name!(...) — but are implemented as a proc macro, which means they can do things pattern-matching could never do. sqlx::query! is the classic example:

#![allow(unused)]
fn main() {
let row = sqlx::query!("SELECT id, name FROM users WHERE id = $1", user_id)
    .fetch_one(&pool)
    .await?;
}

At compile time, sqlx::query! actually connects to your database (or reads a cached schema file) and checks that this SQL is valid and that the columns you’re selecting match the types you’re binding into. Typo a column name and your program fails to compile, with an error pointing at the SQL string. A macro_rules! macro, which only ever sees tokens, could never do that — it has no way to know what’s in your database.

A proc macro has to live in its own crate

Proc macros run as part of the compiler’s job while it’s compiling other code — so the macro itself has to be built and ready to run before that other code is compiled. Rust enforces this with a crate-type: a crate that defines proc macros sets proc-macro = true under [lib] in its Cargo.toml:

[lib]
proc-macro = true

[dependencies]
syn = "2"
quote = "1"
proc-macro2 = "1"

Two consequences follow directly from this:

  • You can’t define a proc macro and use it in the same crate. The macro crate has to be compiled first, as a separate build artifact, then pulled in as a dependency by the crate that wants to call it. This is why every proc macro you’ve used — serde, clap, tokio — ships as its own published crate (serde_derive, clap_derive, and so on), even though you usually only ever type serde::Serialize.
  • A proc-macro = true crate can only export macros. It can’t also export a normal pub fn or pub struct for other crates to use directly.

Actually writing one means parsing the incoming tokens (typically with the syn crate) and generating new tokens back out (typically with quote) — a real jump in complexity that’s worth its own dedicated study once you’re comfortable using proc macros. This lesson deliberately stops at “how to use them correctly,” not “how to build one.”

cargo expand: stop guessing, look at the real code

When a derive or attribute macro does something confusing — or when its generated code fails to compile and the error points at code you never wrote — the fastest way to understand what happened is to look at the actual generated Rust. That’s what cargo expand is for:

cargo install cargo-expand
cargo expand

It runs your crate through the same macro expansion the compiler performs, then pretty-prints the fully expanded source. For a small struct with #[derive(Debug)]:

#![allow(unused)]
fn main() {
struct Point {
    x: i32,
    y: i32,
}
}

cargo expand shows you (roughly) the impl block the derive generated on your behalf:

#![allow(unused)]
fn main() {
impl std::fmt::Debug for Point {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Point")
            .field("x", &self.x)
            .field("y", &self.y)
            .finish()
    }
}
}

No more guessing what a derive “probably” does — you can read exactly what it wrote. This is the single most useful debugging habit for proc macros: when a derive-generated error looks alien, cargo expand turns it back into ordinary Rust you can reason about.

Common mistakes

  • Defining and using a proc macro in the same crate. The compiler rejects this — a proc-macro = true crate must be built separately and depended on from elsewhere, never used from within itself.
  • Forgetting the crate’s derive feature flag. serde = "1" alone does not give you #[derive(Serialize)] — you need serde = { version = "1", features = ["derive"] }. Without it, the derive macro simply doesn’t exist yet, and you get a “cannot find derive macro” error even though serde is a dependency.
  • Confusing a function-like proc macro with a plain macro_rules! macro. They look identical at the call site (name!(...)), but a proc macro like sqlx::query! can do real compile-time work — hitting a database, reading files — so its errors and behavior are far less predictable than pattern matching.
  • Missing the runtime feature flags an attribute macro needs. #[tokio::main] needs tokio = { version = "1", features = ["full"] } (or at least rt-multi-thread and macros) — without the right features enabled, you get errors that look unrelated to the actual missing flag.
  • Staring at your own struct trying to debug a derive-generated error. The error is almost always in the generated code, not your definition. Reach for cargo expand before you reach for guesswork.

More examples

A CLI’s flags for free

#[derive(Parser)] from clap reads a struct’s fields and doc comments and generates all the argument parsing, --help text, and validation — you never hand-write a single match over std::env::args().

use clap::Parser;

#[derive(Parser)]
struct Args {
    /// Name to greet
    name: String,
    /// Number of times to greet
    #[arg(short, long, default_value_t = 1)]
    count: u8,
}

fn main() {
    let args = Args::parse();
    for _ in 0..args.count {
        println!("Hello, {}!", args.name);
    }
}

Async methods on a trait, before Rust supported them natively

#[async_trait] is an attribute macro that rewrites a trait (and its impls) so its methods can be async fn, transforming them into boxed futures behind the scenes — exactly the “rewrite the whole item” behavior #[tokio::main] does for main.

use async_trait::async_trait;

#[async_trait]
trait Notifier {
    async fn send(&self, message: &str);
}

struct EmailNotifier;

#[async_trait]
impl Notifier for EmailNotifier {
    async fn send(&self, message: &str) {
        println!("emailing: {message}");
    }
}

#[tokio::main]
async fn main() {
    let notifier = EmailNotifier;
    notifier.send("your order shipped").await;
}

Compile-time-checked HTML templates

maud’s html! is a function-like macro that parses actual HTML syntax at compile time — a mismatched tag is a compile error, not a runtime bug discovered in a browser.

use maud::html;

fn main() {
    let name = "Ada";
    let markup = html! {
        h1 { "Welcome, " (name) "!" }
    };
    println!("{}", markup.into_string());
}

Generating a builder for a config struct

#[derive(Builder)] from derive_builder writes the whole builder pattern — the setter methods, the build() that checks required fields — from a plain struct definition, so you don’t hand-write it yourself.

use derive_builder::Builder;

#[derive(Builder, Debug)]
struct ServerConfig {
    host: String,
    #[builder(default = "8080")]
    port: u16,
}

fn main() {
    let config = ServerConfigBuilder::default()
        .host("localhost".to_string())
        .build()
        .unwrap();

    println!("{config:?}");
}

Your turn

This code imports Serialize and tries to turn a User into JSON, but it doesn’t compile. Something is missing that would make User an actual Serialize type, not just code that mentions the trait.

// Cargo.toml: serde = { version = "1", features = ["derive"] }, serde_json = "1"
use serde::Serialize;

struct User {
    id: u64,
    name: String,
}

fn main() {
    let user = User { id: 1, name: "Ada".to_string() };
    let json = serde_json::to_string(&user).unwrap();
    println!("{}", json);
}
Show solution

use serde::Serialize; only brings the trait into scope — it doesn’t make User implement it. serde_json::to_string requires T: Serialize, so the compiler rejects this with something like the trait bound 'User: Serialize' is not satisfied. What actually implements the trait for you is the derive macro, and it’s missing:

use serde::Serialize;

#[derive(Serialize)]
struct User {
    id: u64,
    name: String,
}

fn main() {
    let user = User { id: 1, name: "Ada".to_string() };
    let json = serde_json::to_string(&user).unwrap();
    println!("{}", json); // {"id":1,"name":"Ada"}
}

Adding #[derive(Serialize)] is what generates the impl Serialize for User block — the struct definition itself never changes. Importing the trait just lets you refer to it; deriving it is what actually implements it.

Quick check

Remember this

  • Three kinds: derive macros (#[derive(X)]) add code alongside your type; attribute macros (#[tokio::main]) can rewrite the whole item; function-like macros (sqlx::query!(...)) look like macro_rules! calls but run arbitrary compile-time logic.
  • A derive macro never modifies your struct/enum definition — it generates a separate impl block next to it.
  • A proc macro must live in its own crate with proc-macro = true in Cargo.toml — you can’t define and use one in the same crate.
  • cargo expand prints the real generated code — the fastest way to understand, or debug, what a derive or attribute macro actually did.
  • The two most common daily errors are a missing derive feature flag (e.g. serde’s features = ["derive"]) and a missing #[derive(...)] itself — both surface as “trait bound not satisfied” or “cannot find macro” errors.

Go deeper

Next:

Smart pointers

Advanced · Runtime & ecosystem

What & why

A smart pointer is a value that points at some data but also carries extra abilities — like owning the data on the heap, or letting several owners share it. Box, Rc, and Arc are the three you’ll meet first. They solve problems plain ownership can’t: putting a value on the heap, sharing one value among many owners, and doing that safely across threads.

The idea, slowly

A plain variable is its value, sitting right there. A pointer is a value whose job is to say “the real thing is over there.” A smart pointer adds a little brain: it knows how to clean up after itself, count how many owners it has, or hand out shared access. In Rust, smart pointers are just structs that own something and clean it up when they’re dropped — no magic.

Box<T> — put one thing on the heap

By default your values live on the stack (fast, fixed-size, automatic). Sometimes you need a value on the heap instead — because it’s large, or because its size isn’t known at compile time. Box<T> is the simplest smart pointer: it holds a single value on the heap and owns it.

fn main() {
    let boxed = Box::new(42);   // the 42 lives on the heap; `boxed` points at it
    println!("boxed holds {}", boxed);   // use it just like the value
    println!("doubled: {}", *boxed * 2); // * "dereferences" to reach the value
}

You use a Box almost exactly like the value inside it — Rust auto-dereferences in most places. When boxed goes out of scope, it frees the heap memory automatically. Single owner, heap storage, zero fuss.

Box’s real job: recursive types

The classic reason you need a Box: a type that contains itself. Picture a linked list where each node holds the next. Without a Box, the compiler can’t figure out how big a node is (it would be infinitely large), so it errors. A Box breaks the cycle because a Box is always pointer-sized:

// A tiny linked list. Each node points to the next via a Box.
enum List {
    Node(i32, Box<List>),   // Box makes the size finite and known
    End,
}

use List::{Node, End};

fn main() {
    let list = Node(1, Box::new(Node(2, Box::new(Node(3, Box::new(End))))));

    // walk the list and print each value
    let mut current = &list;
    while let Node(value, next) = current {
        println!("{}", value);
        current = next;
    }
}

The compiler is thinking: “A List might contain another List — how big is that? Infinite! But a Box<List> is just a pointer, a fixed known size. Now I can compute the size. Fine.”

Rc<T> — many owners, one value (single thread)

Ownership’s core rule is “one owner.” But sometimes several parts of your program genuinely need to share ownership of the same data, and you can’t say which one should free it. Rc<T> (“Reference Counted”) lets a value have multiple owners. It keeps a count of how many owners exist; when the last one goes away, the value is dropped.

use std::rc::Rc;

fn main() {
    let name = Rc::new(String::from("shared name"));

    let a = Rc::clone(&name);   // +1 owner
    let b = Rc::clone(&name);   // +1 owner

    println!("value: {}", name);
    println!("owners right now: {}", Rc::strong_count(&name));  // 3

    drop(a);
    drop(b);
    println!("owners after dropping two: {}", Rc::strong_count(&name)); // 1
}

Rc::clone is cheap — it does not copy the String. It makes another handle pointing at the same String and bumps the owner count by one. (We write Rc::clone(&name) rather than name.clone() by convention, to make it obvious this is a cheap reference-count bump, not a deep copy.)

Arc<T> — like Rc, but safe across threads

Rc is fast because its counter is not thread-safe — two threads bumping it at once could corrupt it, so the compiler forbids sending an Rc to another thread. When you need shared ownership across threads, use Arc<T> (“Atomically Reference Counted”). It’s the exact same idea with a thread-safe counter. It’s very slightly slower, which is why Rc still exists for single-threaded use.

use std::sync::Arc;
use std::thread;

fn main() {
    let data = Arc::new(vec![1, 2, 3]);
    let mut handles = vec![];

    for id in 0..3 {
        let data = Arc::clone(&data);   // each thread gets its own handle
        handles.push(thread::spawn(move || {
            println!("thread {} sees {:?}", id, data);
        }));
    }

    for h in handles {
        h.join().unwrap();
    }
}

The mental rule: Rc for one thread, Arc when threads are involved. Same behavior, Arc just pays a small cost to be thread-safe.

Choosing between them

  • Need a value on the heap with a single owner? → Box<T>
  • Need several owners of the same value, single-threaded? → Rc<T>
  • Need several owners across threads? → Arc<T>
  • Just one owner and normal size? → you don’t need a smart pointer at all; use the plain value.

Note that Rc and Arc give shared read access. To also mutate shared data you combine them with an interior-mutability type (RefCell for Rc, Mutex for Arc) — that’s the very next lesson.

Common mistakes

  • Reaching for a smart pointer when a plain value works. Most code needs none of these. Use the simplest thing that compiles; add Box/Rc/Arc only when you hit the specific problem it solves.
  • Thinking Rc::clone copies the data. It doesn’t — it just adds an owner and bumps a counter. The underlying value is shared, not duplicated.
  • Using Rc across threads. It won’t compile (Rc isn’t Send). The compiler is protecting you from a data race on the counter. Switch to Arc.
  • Expecting to mutate through Rc/Arc. They hand out shared (immutable) access. To mutate shared data, pair them with RefCell (single-thread) or Mutex (multi-thread).
  • Creating reference cycles with Rc. If two Rcs point at each other, their counts never reach zero and the memory leaks. Use Weak references to break cycles (an advanced follow-up).

More examples

A list of shapes with different types

A drawing program needs to store circles and squares in the same Vec even though they’re different types — Box<dyn Shape> gives every shape a uniform, heap-allocated handle the compiler can treat the same way.

trait Shape {
    fn area(&self) -> f64;
}

struct Circle { radius: f64 }
struct Square { side: f64 }

impl Shape for Circle {
    fn area(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius }
}

impl Shape for Square {
    fn area(&self) -> f64 { self.side * self.side }
}

fn main() {
    let shapes: Vec<Box<dyn Shape>> = vec![
        Box::new(Circle { radius: 2.0 }),
        Box::new(Square { side: 3.0 }),
    ];

    for shape in &shapes {
        println!("area: {:.2}", shape.area());
    }
}

Sharing a parsed config across request handlers

A web server’s route handlers all need to read the same parsed config — Rc lets every handler hold a cheap handle to one shared copy instead of cloning the whole struct per request.

use std::rc::Rc;

struct Config {
    max_connections: u32,
}

fn handle_request(id: u32, config: &Rc<Config>) {
    println!("handler {id} sees max_connections = {}", config.max_connections);
}

fn main() {
    let config = Rc::new(Config { max_connections: 100 });

    for id in 0..3 {
        let handler_config = Rc::clone(&config);
        handle_request(id, &handler_config);
    }

    println!("all handlers done, config still alive");
}

A dictionary shared by spellcheck workers

A spellchecker’s word list is loaded once but needs to be checked by many worker threads at once — Arc shares the read-only dictionary without copying it per thread.

use std::sync::Arc;
use std::thread;

fn main() {
    let dictionary = Arc::new(vec!["rust", "ferris", "cargo", "crate"]);
    let words_to_check = vec!["rust", "python", "cargo"];
    let mut handles = vec![];

    for word in words_to_check {
        let dictionary = Arc::clone(&dictionary);
        handles.push(thread::spawn(move || {
            let found = dictionary.contains(&word);
            println!("{word}: {}", if found { "known" } else { "unknown" });
        }));
    }

    for h in handles {
        h.join().unwrap();
    }
}

Shrinking a token enum with Box

A Token enum where one variant carries a big string and the others don’t makes every Token as large as the biggest variant — boxing just that variant keeps the whole enum small.

enum Token {
    Number(i64),
    Whitespace,
    // Without Box, this variant would make every Token as large as a String (24+ bytes).
    Comment(Box<String>),
}

fn describe(token: &Token) {
    match token {
        Token::Number(n) => println!("number: {n}"),
        Token::Whitespace => println!("whitespace"),
        Token::Comment(text) => println!("comment: {text}"),
    }
}

fn main() {
    let tokens = vec![
        Token::Number(42),
        Token::Whitespace,
        Token::Comment(Box::new(String::from("TODO: fix this"))),
    ];

    for token in &tokens {
        describe(token);
    }
}

Your turn

This program wants two owners to share the same string via Rc, then print how many owners there are. It won’t compile because of a missing import and a wrong clone. Fix it.

fn main() {
    let text = Rc::new(String::from("hi"));
    let second = text.clone_rc();
    println!("owners: {}", Rc::strong_count(&text));
    println!("{} {}", text, second);
}
Show solution

Two problems: Rc needs to be imported from std::rc, and there’s no .clone_rc() method — the way to add an owner is Rc::clone(&text).

use std::rc::Rc;

fn main() {
    let text = Rc::new(String::from("hi"));
    let second = Rc::clone(&text);   // add a second owner (cheap: just bumps the count)
    println!("owners: {}", Rc::strong_count(&text));   // 2
    println!("{} {}", text, second);
}

Rc::clone(&text) makes second a co-owner of the same String, and Rc::strong_count reports 2.

Quick check

Remember this

  • A smart pointer owns data and adds an ability (heap storage, shared ownership) while acting like the value inside.
  • Box<T> = single owner, value on the heap; needed for recursive types and large values.
  • Rc<T> = multiple owners of one value, single-threaded; Rc::clone bumps an owner count, it doesn’t copy.
  • Arc<T> = the thread-safe version of Rc; use it whenever threads share ownership.
  • Rc/Arc give shared read access; combine with RefCell/Mutex to mutate shared data.

Go deeper

Next:

Interior mutability

Advanced · Runtime & ecosystem

What & why

Normally Rust’s rule is simple: to change a value you need a &mut (mutable) reference, and you can only have one at a time. Interior mutability is a carefully controlled exception — it lets you change data even when all you’re holding is a shared & reference. The catch is that the safety check moves from compile time to runtime. Cell, RefCell, and (for threads) Mutex are the tools that do this.

The idea, slowly

Rust’s borrowing rules, from the outside, say: many readers OR one writer, never both. The compiler enforces this by tracking & (shared, read-only) and &mut (exclusive, read-write) references. It’s brilliant, but occasionally too strict — there are safe patterns it can’t prove are safe. Interior mutability is the escape hatch: a type that lets you mutate through a shared &, and takes on the job of enforcing the rules itself, at runtime, instead of asking the compiler.

Think of &mut as a physical key that only one person can hold. Interior mutability is like a room with a sign-in sheet by the door instead of a key: anyone can walk up (shared &), but the sheet enforces “only one person editing at a time” — and if you break the rule, you find out the moment you try, not before.

RefCell<T> — borrow rules checked at runtime

RefCell is the one you’ll meet most. From the outside it looks immutable (you hold a plain & to it), but it hands out mutable access on request through two methods:

  • .borrow() gives you a shared read handle (Ref).
  • .borrow_mut() gives you an exclusive write handle (RefMut).

RefCell keeps a little counter and enforces “many readers or one writer” at runtime. Break the rule and it panics instead of failing to compile.

use std::cell::RefCell;

fn main() {
    let value = RefCell::new(String::from("rust"));

    value.borrow_mut().push('!');   // mutate through a shared &
    value.borrow_mut().push('!');

    println!("{}", value.borrow()); // read it: "rust!!"
}

The compiler is thinking: value is not declared mut, and I only ever see shared & to it. Yet it’s being mutated? Normally I’d reject that — but RefCell promised to police the borrows itself at runtime, so I’ll allow it.”

The runtime panic you must respect

Because the check is at runtime, you can write code that compiles fine but panics when it runs, if you hold a read and a write borrow at the same time:

use std::cell::RefCell;

fn main() {
    let data = RefCell::new(vec![1, 2, 3]);

    let reader = data.borrow();        // read borrow is alive...
    // data.borrow_mut();              // <- uncomment: PANIC "already borrowed"
    println!("reading: {:?}", reader); // reader still in use here

    // reader is dropped at end of scope; now a write borrow would be fine
    drop(reader);
    data.borrow_mut().push(4);
    println!("after: {:?}", data.borrow());
}

Uncomment the middle line and Run: it compiles, then panics with already borrowed: BorrowMutError. That’s RefCell doing at runtime the exact job the compiler normally does at compile time. The borrow rules never went away — they just moved.

Cell<T> — the simpler cousin for Copy values

Cell<T> is a lighter version for small Copy types (numbers, bool). It doesn’t hand out references at all; you just .get() a copy out or .set() a new value in. Because it never lends a reference, it can’t have a borrow conflict, so it never panics.

use std::cell::Cell;

fn main() {
    let counter = Cell::new(0);

    counter.set(counter.get() + 1);
    counter.set(counter.get() + 1);

    println!("counter = {}", counter.get());   // 2
}

Use Cell for simple Copy values, RefCell for everything else.

The famous combo: Rc<RefCell<T>>

Remember from the last lesson that Rc<T> lets many owners share a value — but only for reading. Pair it with RefCell and you get shared ownership that can also be mutated: Rc<RefCell<T>>. Several owners, any of whom can change the inside. This shows up constantly in tree and graph structures.

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

fn main() {
    let shared = Rc::new(RefCell::new(vec![1, 2, 3]));

    let a = Rc::clone(&shared);   // another owner
    let b = Rc::clone(&shared);   // and another

    a.borrow_mut().push(4);       // mutate through one owner
    b.borrow_mut().push(5);       // mutate through another

    println!("{:?}", shared.borrow());   // [1, 2, 3, 4, 5]
}

Both a and b co-own the vector and can push to it. Rc provides the sharing, RefCell provides the mutability.

For threads: Mutex<T> and RwLock<T>

Cell and RefCell are single-threaded only — they aren’t safe to share across threads, and the compiler won’t let you. The thread-safe equivalents are:

  • Mutex<T> — like RefCell but for threads. .lock() gives exclusive access; other threads wait. Instead of panicking on conflict, threads block until the lock is free.
  • RwLock<T> — allows many simultaneous readers OR one writer, for when reads vastly outnumber writes.

You saw Arc<Mutex<T>> in the concurrency lesson — that’s the multi-threaded twin of Rc<RefCell<T>>: shared ownership plus safe mutation, across threads.

Single-threadedMulti-threaded (across threads)
Rc<T>Arc<T>
RefCell<T>Mutex<T> / RwLock<T>
Rc<RefCell<T>>Arc<Mutex<T>>

Common mistakes

  • Forgetting the check is now at runtime. RefCell code that violates borrow rules compiles but panics when it runs. You’ve traded a compile error for a crash, so test the paths.
  • Holding a borrow longer than you meant. A Ref/RefMut from .borrow()/.borrow_mut() keeps the borrow alive until it’s dropped. Store it in a variable that lingers and you can accidentally block a later borrow and panic. Keep borrows short; drop them early if needed.
  • Using RefCell across threads. It isn’t thread-safe and won’t compile in a threaded context. Use Mutex (or RwLock) instead.
  • Reaching for interior mutability to dodge good design. It’s for genuine patterns the compiler can’t prove (shared graphs, callbacks). If a plain &mut or restructuring works, prefer that — interior mutability adds runtime cost and a panic risk.
  • Confusing Cell and RefCell. Cell is for Copy values via get/set and never panics; RefCell lends references and enforces borrows at runtime.

More examples

A logger that records messages through a shared &self

Logging methods usually take &self, not &mut self, so every caller can hold a shared reference — RefCell lets log push onto an internal Vec anyway.

use std::cell::RefCell;

struct Logger {
    messages: RefCell<Vec<String>>,
}

impl Logger {
    fn new() -> Self {
        Logger { messages: RefCell::new(Vec::new()) }
    }

    fn log(&self, message: &str) {
        self.messages.borrow_mut().push(message.to_string());
    }
}

fn main() {
    let logger = Logger::new();
    logger.log("server started");
    logger.log("listening on port 8080");

    println!("{:?}", logger.messages.borrow());
}

Counting cache hits inside a read-only lookup

A cache’s get method looks read-only from the outside, but tracking how often it’s hit needs to mutate a counter every call — Cell handles that without changing get’s &self signature.

use std::cell::Cell;

struct Cache {
    value: i32,
    hits: Cell<u32>,
}

impl Cache {
    fn get(&self) -> i32 {
        self.hits.set(self.hits.get() + 1);
        self.value
    }
}

fn main() {
    let cache = Cache { value: 42, hits: Cell::new(0) };

    cache.get();
    cache.get();
    cache.get();

    println!("value looked up {} times", cache.hits.get());
}

Two systems mutating shared game state

A damage system and a healing system both need to change the same player’s health — Rc<RefCell<Player>> lets both hold an owner and mutate the same struct instead of copying it back and forth.

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

struct Player {
    health: i32,
}

fn main() {
    let player = Rc::new(RefCell::new(Player { health: 100 }));

    let damage_system = Rc::clone(&player);
    let healing_system = Rc::clone(&player);

    damage_system.borrow_mut().health -= 30;
    healing_system.borrow_mut().health += 10;

    println!("player health: {}", player.borrow().health);
}

Flipping a maintenance-mode flag read by many handlers

Every request handler needs to check the same maintenance flag, but none of them own it — Cell<bool> lets any of them read it and lets one admin action flip it.

use std::cell::Cell;

struct AppState {
    maintenance_mode: Cell<bool>,
}

fn handle_request(state: &AppState) {
    if state.maintenance_mode.get() {
        println!("503: site is under maintenance");
    } else {
        println!("200: serving request normally");
    }
}

fn main() {
    let state = AppState { maintenance_mode: Cell::new(false) };

    handle_request(&state);

    state.maintenance_mode.set(true);
    handle_request(&state);
}

Your turn

This program wants to increment a counter that lives behind a shared RefCell, then print it. It won’t compile because it tries to mutate through a plain method instead of borrowing mutably. Fix it.

use std::cell::RefCell;

fn main() {
    let count = RefCell::new(0);

    count.set(count.get() + 1);   // RefCell has no get/set!
    count.set(count.get() + 1);

    println!("count = {}", count.borrow());
}
Show solution

get/set belong to Cell, not RefCell. With a RefCell you get a mutable borrow and change the value through it:

use std::cell::RefCell;

fn main() {
    let count = RefCell::new(0);

    *count.borrow_mut() += 1;   // borrow mutably, then use * to reach the value
    *count.borrow_mut() += 1;

    println!("count = {}", count.borrow());   // 2
}

Each count.borrow_mut() hands you an exclusive write handle; * dereferences it so += 1 changes the number inside. Each borrow is released at the end of its statement, so they don’t conflict. (For a plain number like this, Cell with get/set would also work — but borrow_mut is the RefCell way.)

Quick check

Remember this

  • Interior mutability lets you mutate through a shared &, moving the borrow check from compile time to runtime.
  • RefCell<T>: .borrow() / .borrow_mut() enforce “many readers or one writer” at runtime — and panic if you break it.
  • Cell<T> is the simpler get/set version for Copy values; it never panics.
  • Rc<RefCell<T>> = shared ownership plus mutation, single-threaded; Arc<Mutex<T>> is the thread-safe twin.
  • RefCell/Cell are single-threaded only; use Mutex/RwLock across threads.

Go deeper

Next:

Unsafe Rust

Advanced · Runtime & ecosystem

What & why

unsafe is a keyword that unlocks a handful of operations the compiler normally forbids because it can’t prove they’re safe. It does not switch off safety for your whole program — it draws a small marked box and says “inside here, I, the programmer, take responsibility for the rules.” You’ll rarely write it, but you should understand it, because a lot of the safe standard library is built on top of tiny, carefully audited unsafe blocks.

The idea, slowly

Everything you’ve written so far is safe Rust: the compiler checks ownership, borrowing, and types, and guarantees no dangling pointers, no data races, no reading freed memory. That guarantee is Rust’s whole selling point.

But the compiler is conservative. It rejects some things that are actually fine, simply because it can’t verify them. And some low-level tasks — talking to C libraries, writing a data structure at the raw-memory level, poking hardware — inherently can’t be proven safe by any compiler. For those cases, unsafe lets you do five extra things:

  1. Dereference a raw pointer (*const T / *mut T).
  2. Call an unsafe function (including foreign C functions).
  3. Access or modify a mutable static (global) variable.
  4. Implement an unsafe trait.
  5. Access fields of a union.

That’s the entire list. unsafe gives you these five powers and nothing else. It does not turn off the borrow checker for normal code, it doesn’t let you ignore types, and it isn’t a magic “make the error go away” button.

What unsafe really means: a promise

Inside an unsafe block, the compiler stops checking a few specific things and trusts you to keep the rules it can no longer verify. You are signing a contract: “I promise this pointer is valid, this memory is initialized, this C function does what its docs say.” If you break the promise, you get the exact bugs Rust normally prevents — crashes, corruption, security holes. That’s why the advice is: keep unsafe blocks tiny and stare at them hard.

Raw pointers

A raw pointer is like a reference with the safety training wheels removed. You can create raw pointers in safe code; you can only dereference them (follow them to the value) inside unsafe.

fn main() {
    let x = 42;

    let p = &x as *const i32;   // make a raw pointer (safe so far)

    unsafe {
        // Dereferencing needs unsafe: the compiler can't guarantee p is valid.
        println!("p points at {}", *p);
    }
}

Creating p is safe — a pointer is just a number. Following it with *p is where things could go wrong (what if it pointed at freed memory?), so that requires unsafe. Here it’s clearly fine because x is right there, alive, on the stack.

Calling an unsafe function

Some functions are marked unsafe fn because calling them wrongly causes undefined behavior. Calling one requires an unsafe block, which is you saying “I’ve read the contract and I’m meeting it.”

// A function that is only correct if `index` is within bounds.
unsafe fn get_unchecked(slice: &[i32], index: usize) -> i32 {
    // std has slice::get_unchecked; we fake the idea here.
    *slice.as_ptr().add(index)
}

fn main() {
    let numbers = [10, 20, 30];

    let value = unsafe {
        // WE promise index 1 is in bounds. If we lied, this is UB.
        get_unchecked(&numbers, 1)
    };

    println!("value = {}", value);   // 20
}

The whole point of the unsafe fn marking is to force every caller to acknowledge the danger with an unsafe block, so it’s visible in the code and in code review.

The golden pattern: wrap unsafe in a safe API

Well-written Rust doesn’t scatter unsafe everywhere. It hides a small unsafe core behind a safe function that checks the conditions first, so callers never touch unsafe at all. The standard library does this constantly — Vec, for instance, is a safe wrapper around unsafe raw-memory code.

// Safe on the outside, unsafe (checked) on the inside.
fn third_element(slice: &[i32]) -> Option<i32> {
    if slice.len() > 2 {
        // We just proved index 2 is valid, so the unsafe deref is sound.
        Some(unsafe { *slice.as_ptr().add(2) })
    } else {
        None
    }
}

fn main() {
    println!("{:?}", third_element(&[1, 2, 3, 4]));   // Some(3)
    println!("{:?}", third_element(&[1, 2]));         // None — no crash
}

Callers of third_element never write unsafe. The dangerous operation is boxed in, guarded by a bounds check that makes the promise true. This is the responsible way to use unsafe.

When do you actually need it?

Honestly, as a beginner (and often for years): almost never in application code. You reach for unsafe when:

  • Calling into C libraries (FFI — the next lesson).
  • Writing a low-level data structure where you manage memory yourself.
  • Doing a performance-critical trick after you’ve measured that the safe version is too slow.

If you’re writing a web app, a CLI, or a normal service, you can go a very long time without ever typing unsafe. Treat wanting it as a signal to double-check there isn’t a safe way first.

Common mistakes

  • Using unsafe to silence a borrow-checker error. It doesn’t do that. unsafe only unlocks the five specific operations above; a normal ownership error inside an unsafe block is still an error. If the borrow checker is complaining, fix the design, don’t reach for unsafe.
  • Making the unsafe block bigger than necessary. Wrap only the actual dangerous operation, not a whole function of ordinary code. A small block is easy to audit; a huge one hides the real risk.
  • Breaking the unspoken contract. Dereferencing a dangling or misaligned pointer, calling a C function with wrong arguments, or reading uninitialized memory is undefined behavior — the program may crash, corrupt data, or appear to work then fail later. There is no partial credit.
  • Not documenting why it’s sound. Every unsafe block should have a comment explaining why the promise holds (“index checked above,” “pointer came from a live Vec”). Future you needs it.
  • Assuming unsafe is faster by default. It isn’t magic speed. Often the safe version optimizes to identical machine code. Only reach for it after measuring a real bottleneck.

More examples

Writing a minimal mem::swap

The standard library’s mem::swap looks like an ordinary function, but swapping the contents of two &mut Ts without a spare copy needs unsafe underneath — this is roughly how it works.

fn my_swap<T>(a: &mut T, b: &mut T) {
    unsafe {
        let temp = std::ptr::read(a);
        std::ptr::write(a, std::ptr::read(b));
        std::ptr::write(b, temp);
    }
}

fn main() {
    let mut x = String::from("left");
    let mut y = String::from("right");

    my_swap(&mut x, &mut y);

    println!("x = {x}, y = {y}");
}

A global ID counter with a mutable static

A quick global ID generator is the classic reason to reach for a mutable static — but every read or write of it needs unsafe, because the compiler can’t rule out two threads touching it at once.

static mut NEXT_ID: u32 = 0;

fn next_id() -> u32 {
    unsafe {
        NEXT_ID += 1;
        NEXT_ID
    }
}

fn main() {
    println!("{}", next_id()); // 1
    println!("{}", next_id()); // 2
    println!("{}", next_id()); // 3
}

Reinterpreting bits with a union

A union lets two different types share the same memory — reading whichever field you choose is unsafe because the compiler can’t check that the bytes actually mean what you’re asking for.

union FloatOrInt {
    f: f32,
    i: i32,
}

fn main() {
    let value = FloatOrInt { i: 1_078_530_011 };

    unsafe {
        println!("as int: {}", value.i);
        println!("as float (same bits, reinterpreted): {}", value.f);
    }
}

Computing a checksum over a struct’s raw bytes

Computing a checksum by reading a struct’s raw bytes needs unsafe, because reinterpreting a &T as a &[u8] bypasses everything the compiler knows about the type.

struct Point {
    x: i32,
    y: i32,
}

fn checksum(value: &Point) -> u8 {
    let ptr = value as *const Point as *const u8;
    let len = std::mem::size_of::<Point>();

    // SAFETY: `ptr` comes from a valid, live `&Point`, and `len` matches its exact size.
    let bytes = unsafe { std::slice::from_raw_parts(ptr, len) };

    bytes.iter().fold(0u8, |acc, b| acc.wrapping_add(*b))
}

fn main() {
    let p = Point { x: 10, y: 20 };
    println!("checksum: {}", checksum(&p));
}

Your turn

Unsafe/low-level code is best reasoned about rather than fiddled with blindly, so this is a “what’s wrong here” exercise. The function below claims to be safe but has a serious bug. What’s the problem, and how would you make it genuinely sound?

fn first_element(slice: &[i32]) -> i32 {
    // "It's fine, index 0 always exists... right?"
    unsafe { *slice.as_ptr() }
}
Show solution

The bug: an empty slice has no element 0. If someone calls first_element(&[]), slice.as_ptr() points at nothing valid, and dereferencing it is undefined behavior — a crash or garbage. The function pretends to be safe but can trigger UB from perfectly ordinary safe input, which is exactly what you must never do.

Make the promise true by checking before you dereference, and return an Option so an empty slice has a real answer:

fn first_element(slice: &[i32]) -> Option<i32> {
    if slice.is_empty() {
        None
    } else {
        // SAFETY: we just checked the slice is non-empty, so index 0 is valid.
        Some(unsafe { *slice.as_ptr() })
    }
}

Now the unsafe deref only runs when we’ve proven there’s an element there, and the empty case returns None instead of corrupting memory. Better still, plain safe Rust already does this: slice.first().copied(). Prefer the safe standard-library method whenever one exists.

Quick check

Remember this

  • unsafe unlocks exactly five operations (raw-pointer deref, unsafe fn calls, mutable statics, unsafe traits, unions) — nothing more.
  • It does not disable the borrow checker or safety for the rest of your program; it’s a small, marked promise.
  • Break the promise (dangling pointer, wrong FFI call, uninitialized memory) and you get undefined behavior.
  • The right pattern is a safe API wrapping a small, checked unsafe core — like Vec does.
  • Keep unsafe blocks tiny, document why they’re sound, and prefer a safe alternative whenever one exists.

Go deeper

Next:

FFI

Advanced · Runtime & ecosystem

What & why

FFI stands for Foreign Function Interface — it’s how Rust talks to code written in another language, almost always C. You use it to call an existing C library (image decoders, databases, the operating system) from Rust, or to let a C program call your Rust code. The hard part isn’t the syntax; it’s agreeing with the other language about how data is shaped, who frees memory, and what happens when things go wrong.

The idea, slowly

Two languages meeting at a border

Imagine two countries that speak different languages, meeting at a border crossing. Inside Rust, everyone follows Rust’s strict rules: the borrow checker watches every value, memory is freed automatically, strings know their length. Inside C, none of that is true — C strings are just bytes that end in a zero, nobody checks anything, and you free memory by hand.

FFI is the border crossing between them. At that border, Rust’s guarantees stop. The compiler can check your Rust up to the edge, but once a value crosses into C, Rust has no idea what happens to it. That’s why everything about FFI is marked unsafe: you are telling the compiler “I’ve checked this by hand, trust me.”

The extern block: declaring foreign functions

To call a C function, you first declare it so Rust knows its name and its signature. You do that in an extern "C" block. The "C" part is the ABI — the “application binary interface,” the low-level agreement about how arguments are passed in registers and on the stack. C and Rust don’t naturally agree on this, so you spell it out.

// This declares a function that lives in the C standard library.
// Rust does NOT define it here — it just promises "this exists somewhere."
extern "C" {
    fn abs(input: i32) -> i32;
}

fn main() {
    // Calling a foreign function is ALWAYS unsafe, because Rust can't verify
    // that `abs` actually behaves the way we claimed.
    let result = unsafe { abs(-5) };
    println!("abs(-5) = {result}");
}

This example links against the C standard library, so it will not run on the Playground’s Run button reliably. Put it in a real project and run cargo run. The point to absorb: you declare the function’s shape, and every call sits inside unsafe { }.

Going the other way: exposing Rust to C

Sometimes you want C (or Python, or Node, or a game engine) to call your Rust function. Two things have to happen:

  • extern "C" on the function tells Rust to use the C calling convention so C knows how to call it.
  • #[unsafe(no_mangle)] stops Rust from “mangling” the name. Normally Rust scrambles function names into long unique symbols; C wouldn’t be able to find add if it were renamed to something like _ZN3add17h9f.... no_mangle keeps the name exactly add.
#![allow(unused)]
fn main() {
// In a real library crate (a `cdylib` or `staticlib`), C can now call `add`.
#[unsafe(no_mangle)]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
    a + b
}
}

On older Rust you’ll see plain #[no_mangle]; modern editions prefer the explicit #[unsafe(no_mangle)] because exporting a raw symbol is itself an unsafe promise. Both compile; the unsafe(...) form is the current recommendation.

The real challenge: data layout and ownership

Simple numbers like i32 cross the border fine — C and Rust agree on what a 32-bit integer is. The trouble starts with anything bigger:

  • Strings. A Rust String knows its length and is not zero-terminated. A C string is just bytes ending in a \0. They are not interchangeable. You convert with std::ffi::CString (Rust → C) and CStr (C → Rust).
  • Structs. Rust is free to reorder struct fields for efficiency. C never does. If you share a struct across the border, you must add #[repr(C)] so Rust lays it out exactly the way C expects.
  • Ownership. This is the big one. If Rust allocates memory and hands a pointer to C, who frees it? If both free it, you get a crash. If neither frees it, you leak. FFI has no borrow checker to sort this out — you decide the rule and document it loudly.

The golden pattern: wrap the unsafe part

The professional move is to keep all the scary unsafe FFI calls in one small private module, and wrap them in a normal, safe Rust function that the rest of your program uses. The unsafe code is tiny and auditable; everyone else gets a friendly, checked interface.

Common mistakes

  • Forgetting #[repr(C)] on shared structs. Rust may reorder or pad fields differently than C, so the two sides read each other’s data at the wrong offsets. It compiles, then corrupts data at runtime — the worst kind of bug.
  • Assuming a Rust String is a C string. It isn’t zero-terminated and can contain interior nulls, so passing its bytes straight to C reads past the end or stops early. Convert with CString/CStr.
  • Getting the ABI wrong (extern "C" missing). Without the right ABI, arguments land in the wrong places and the call quietly produces garbage or crashes.
  • Confusing ownership across the border. Freeing memory on the wrong side (or on both sides) causes double-frees and use-after-free. There’s no compiler to catch it — you must define and document who owns what.
  • Skipping unsafe mentally. FFI compiles to real machine calls with zero checking. Treat every boundary as a place a bug can hide.

More examples

Computing a square root via the C math library

Not every C function needs a custom library — sqrt already lives in the C standard library, so declaring it is enough to borrow it instead of reimplementing it in Rust.

// Links against the C math library — run in a real project, not the Playground.
extern "C" {
    fn sqrt(x: f64) -> f64;
}

fn main() {
    let result = unsafe { sqrt(64.0) };
    println!("sqrt(64.0) = {result}");
}

Measuring a C string’s length safely

Handing a C function a raw Rust String would read garbage past the end, since C expects a zero-terminated string — CString builds one that strlen can safely walk.

// Links against the C standard library — run in a real project, not the Playground.
use std::ffi::CString;

extern "C" {
    fn strlen(s: *const i8) -> usize;
}

fn main() {
    let greeting = CString::new("hello from rust").expect("no interior nulls");
    let length = unsafe { strlen(greeting.as_ptr()) };
    println!("C measured the string at {length} bytes");
}

A C program validating downloaded files needs a fast checksum — exporting one from Rust with no_mangle lets it call straight into compiled Rust code instead of a slower C implementation.

#![allow(unused)]
fn main() {
// In a real library crate (a `cdylib`), a C program could link against this and call `checksum`.
#[unsafe(no_mangle)]
pub extern "C" fn checksum(data: *const u8, len: usize) -> u32 {
    let bytes = unsafe { std::slice::from_raw_parts(data, len) };
    bytes.iter().map(|&b| b as u32).sum()
}
}

Wrapping an unsafe sensor-reading call in a safe API

A C sensor driver returns readings through an out-pointer and a status code — wrapping that in a function returning Option<SensorReading> means the rest of the program never touches unsafe directly.

// Links against a C sensor library — run in a real project, not the Playground.
#[repr(C)]
struct SensorReading {
    temperature_c: f32,
    humidity_pct: f32,
}

extern "C" {
    fn read_sensor(out: *mut SensorReading) -> i32;
}

fn read_sensor_safe() -> Option<SensorReading> {
    let mut reading = SensorReading { temperature_c: 0.0, humidity_pct: 0.0 };
    let status = unsafe { read_sensor(&mut reading) };
    if status == 0 {
        Some(reading)
    } else {
        None
    }
}

fn main() {
    match read_sensor_safe() {
        Some(r) => println!("{}C, {}%", r.temperature_c, r.humidity_pct),
        None => println!("sensor read failed"),
    }
}

Your turn

This one is a spot-the-bug, because FFI needs a real toolchain and can’t run on the Playground. Here is a struct a beginner wants to share with a C library. Two things are wrong for FFI. What are they, and why do they bite?

#![allow(unused)]
fn main() {
// Meant to be passed by pointer into a C function.
struct Point {
    x: i32,
    y: i32,
}

extern {
    fn draw_point(p: *const Point);
}
}
Show solution

Two fixes:

#[repr(C)]              // 1. force C-compatible field layout
struct Point {
    x: i32,
    y: i32,
}

extern "C" {           // 2. name the ABI explicitly
    fn draw_point(p: *const Point);
}

fn main() {
    let p = Point { x: 3, y: 4 };
    unsafe { draw_point(&p); }   // and every call is unsafe
}

Why each matters:

  1. #[repr(C)] — without it, Rust is allowed to reorder or pad the fields however it likes, so C might read x where Rust put y. It compiles cleanly and then corrupts data at runtime.
  2. extern "C" — a bare extern doesn’t state the ABI clearly. Naming "C" guarantees Rust and C agree on how arguments and pointers are passed.

And notice the call itself is wrapped in unsafe { } — dereferencing a raw pointer in C code is something only you can vouch for.

Quick check

Remember this

  • FFI is the border between Rust and another language (usually C); Rust’s safety guarantees stop at that border.
  • extern "C" { ... } declares foreign functions; calling them is always unsafe.
  • #[unsafe(no_mangle)] pub extern "C" fn exposes a Rust function to C with an unscrambled name.
  • Put #[repr(C)] on any struct that crosses the boundary, and convert strings with CString/CStr.
  • Decide explicitly who owns and frees memory — there is no borrow checker across FFI.
  • Wrap the tiny unsafe FFI core in a safe Rust API for everyone else to use.

Go deeper

Next:

Dates and time

Intermediate · Runtime & ecosystem

What & why

“Time” in a program actually means two different things, and mixing them up causes real bugs. Sometimes you want to know how long something took — a stopwatch. Sometimes you want to know what calendar date and time it is — a clock. Rust’s standard library gives you a proper stopwatch (Instant) and a raw wall clock (SystemTime), but deliberately has no calendar type at all — no year, month, day, timezone. For real dates you reach for the chrono crate, the ecosystem’s answer to “what day is it, and how do I format it?”

The idea, slowly

Instant — a stopwatch that can’t lie to you

Instant::now() captures a point on a monotonic clock: one that only ever moves forward, and is completely unaffected by someone adjusting the system clock (daylight saving, NTP sync, a user manually changing their laptop’s date). Call .elapsed() on it later to get a Duration — exactly how much time has passed.

use std::time::Instant;

fn main() {
    let start = Instant::now();

    let mut sum: u64 = 0;
    for i in 0..1_000_000u64 {
        sum = sum.wrapping_add(i);
    }

    let elapsed = start.elapsed();
    println!("summed to {sum} in {elapsed:?}");
}

Instant is deliberately opaque — you can’t turn one into a calendar date, print it as “August 20th,” or serialize it to disk and compare it after a restart. It only makes sense compared to another Instant from the same run of the same program. That narrowness is the whole point: it exists for exactly one job, measuring elapsed time, and it’s immune to the ways a wall clock can jump around.

SystemTime — the real wall clock, which can jump

SystemTime::now() gives you the actual wall-clock time — the one a user could change by fiddling with their system settings, or that an NTP sync could nudge backward by a few milliseconds. That’s exactly why comparing two SystemTimes returns a Result, not a plain Duration:

use std::time::{SystemTime, UNIX_EPOCH};

fn main() {
    let now = SystemTime::now();
    match now.duration_since(UNIX_EPOCH) {
        Ok(elapsed) => println!("seconds since the Unix epoch: {}", elapsed.as_secs()),
        Err(e) => println!("system clock is set before 1970: {e}"),
    }
}

A Duration in Rust can never be negative, but a wall clock genuinely can go backward relative to some reference point. So duration_since hands back Err instead of pretending a negative duration makes sense. The compiler is thinking: “You asked for the gap between two wall-clock readings — I can’t promise that gap is positive, so you get a Result, not a bare Duration.” Use SystemTime for timestamps you want to store or display (SystemTime::now() as “when did this happen”); use Instant when you’re timing how long something takes.

chrono — actual calendar dates, formatting, and parsing

Neither Instant nor SystemTime knows what a “month” or a “timezone” is — they’re just points on a clock. For real calendar work (dates, formatting, parsing, timezones), the ecosystem standard is chrono.

cargo add chrono
// chrono is an external crate — add it first (above), then run in a real project.
use chrono::{DateTime, Utc};

fn main() {
    let now: DateTime<Utc> = Utc::now();
    println!("now (UTC): {}", now.format("%Y-%m-%d %H:%M:%S"));

    // Parsing text back into a real date:
    let parsed = DateTime::parse_from_rfc3339("2026-08-20T15:30:00Z")
        .expect("invalid timestamp");
    println!("parsed: {}", parsed.format("%A, %B %d, %Y"));
}

chrono::Utc::now() returns a DateTime<Utc> — a real calendar timestamp that knows its own timezone (UTC, in this case). .format("%Y-%m-%d %H:%M:%S") uses strftime-style format specifiers (%Y = 4-digit year, %m = month, %d = day, and so on) to turn it into readable text; DateTime::parse_from_rfc3339 goes the other direction, turning text into a DateTime. If you want the local timezone instead of UTC, chrono::Local::now() gives you a DateTime<Local> — but be deliberate about which one you’re using, since comparing a Utc time to a Local time without converting first is a classic source of off-by-several-hours bugs.

Common mistakes

  • Using SystemTime to measure how long something took. It can jump backward if the system clock is adjusted mid-measurement, silently producing a wrong (or Err-returning) duration. Instant is immune to this — use it for timing.
  • Expecting Instant to tell you a calendar date. It has no .format() method and can’t be turned into “August 20th” — it’s just an opaque stopwatch reading. Reach for chrono when you need an actual date.
  • Mixing up %M and %m in a format string. %m is the month, %M is minutes — one letter case flips an entire field. Always check a format string against real output once.
  • Comparing a Utc time to a Local time directly. They represent the same instant differently depending on the machine’s timezone; convert one to match the other (.with_timezone(&Utc)) before comparing.
  • Assuming duration_since always succeeds. It returns Err if the earlier time is actually later — don’t reach for .unwrap() on it without thinking about why that Result exists.

More examples

Timing a sort to catch a slow algorithm early

Sorting is one of the first things worth timing when a data pipeline feels sluggish — wrapping just the sort() call in Instant::now()/.elapsed() isolates that one step from everything around it.

use std::time::Instant;

fn main() {
    let mut nums: Vec<i32> = (0..50_000).rev().collect();

    let start = Instant::now();
    nums.sort();
    let elapsed = start.elapsed();

    println!("sorted {} numbers in {elapsed:?}", nums.len());
}

Stamping a log entry with when it happened

A log entry needs to record when it happened, not how long anything took — seconds since the Unix epoch from SystemTime is a compact, storable timestamp for exactly that.

use std::time::{SystemTime, UNIX_EPOCH};

struct LogEntry {
    message: String,
    created_at: u64, // seconds since the Unix epoch
}

fn main() {
    let created_at = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("system clock is before 1970")
        .as_secs();

    let entry = LogEntry {
        message: "server started".to_string(),
        created_at,
    };

    println!("[{}] {}", entry.created_at, entry.message);
}

Showing when an account was created

A profile page wants “joined August 31, 2026,” not a raw timestamp — chrono’s .format() turns a DateTime<Utc> into exactly that.

use chrono::{DateTime, Utc};

struct Account {
    username: String,
    joined: DateTime<Utc>,
}

fn main() {
    let account = Account {
        username: "shaon07".to_string(),
        joined: Utc::now(),
    };

    println!(
        "{} joined on {}",
        account.username,
        account.joined.format("%B %d, %Y")
    );
}

Counting down to a deadline

A project tracker’s “days left” number is just calendar subtraction — parse the deadline with NaiveDate, subtract today, and read .num_days() off the result.

use chrono::{Local, NaiveDate};

fn main() {
    let deadline = NaiveDate::parse_from_str("2026-12-25", "%Y-%m-%d")
        .expect("invalid date");
    let today = Local::now().date_naive();

    let days_left = (deadline - today).num_days();
    println!("{days_left} day(s) until the deadline");
}

Your turn

This program is supposed to print how long a loop took — but it doesn’t compile.

use std::time::Instant;

fn main() {
    let now = Instant::now();
    println!("{}", now.format("%Y-%m-%d")); // bug!
}
Show solution

Instant has no .format() method — it isn’t a calendar date at all, just an opaque point on a monotonic clock with no year, month, or day attached to it. The compiler rejects this with something like no method named \format` found for struct `Instant` in the current scope. Formatting like “%Y-%m-%d”is achrono DateTimeoperation, not something anystd::time` type can do.

use std::time::Instant;

fn main() {
    let start = Instant::now();
    // ... do some work ...
    let elapsed = start.elapsed();
    println!("{elapsed:?}"); // Duration implements Debug — this is what Instant is for
}

Instant only ever answers “how much time passed” via .elapsed(), which returns a Duration you can {:?}-print directly. If what you actually want is “what’s today’s date, formatted nicely,” that’s chrono::Utc::now().format("%Y-%m-%d") — a completely different type, for a completely different question.

Quick check

Remember this

  • Instant::now() + .elapsed() measures elapsed time for timing code — monotonic, immune to system clock changes, and can’t be turned into a calendar date.
  • SystemTime is the real wall clock — use it for timestamps, not for measuring durations, since it can jump if the clock is adjusted.
  • chrono::DateTime<Utc> / Local is the standard type for actual calendar dates: .format(...) to print, DateTime::parse_from_rfc3339(...) to parse.
  • duration_since returns a Result, not a bare Duration, because a wall clock can genuinely go backward.
  • Be explicit about Utc vs. Local — comparing across them without converting is a classic off-by-hours bug.

Go deeper

Next:

Regex and text processing

Intermediate · Runtime & ecosystem

What & why

Regular expressions are a powerful way to describe patterns in text — “four digits, a dash, two digits” — and check, find, or extract them. Rust’s standard library has no regex support at all; the ecosystem standard is the regex crate. But regex is also easy to reach for out of habit when a plain str method would be clearer, faster to write, and dependency-free. This lesson covers both: the std tools worth trying first, and regex done properly when you actually need pattern matching.

The idea, slowly

Try plain str methods first

Before adding a dependency, ask: is this really a pattern, or just a fixed piece of text? A huge amount of “text processing” is actually just splitting, trimming, and checking prefixes/suffixes — all built into str, no regex required.

fn main() {
    let email = "shaon@example.com";

    // Splitting on a literal character — no pattern matching needed.
    if let Some((user, domain)) = email.split_once('@') {
        println!("user: {user}, domain: {domain}");
    }

    let line = "   hello world   ";
    println!("trimmed: {:?}", line.trim());

    let path = "src/main.rs";
    println!("is a rust file: {}", path.ends_with(".rs"));
    println!("lives under src/: {}", path.starts_with("src/"));
    println!("contains 'main': {}", path.contains("main"));
}

Every one of those reads clearly, compiles instantly, and needs zero extra crates. split_once, trim, starts_with, ends_with, and contains cover a surprising fraction of what people reach for regex to do. Save regex for when the shape you’re matching genuinely varies — repeated digits, optional parts, alternatives — not fixed substrings.

Compiling a Regex — once

When you do need real pattern matching, add the crate:

cargo add regex

Regex::new(pattern) compiles the pattern into a matching engine, and that compilation step is the expensive part — meaningfully more work than actually running a match. Compile a pattern exactly once, and reuse the same Regex for every match after that.

// regex is an external crate — add it first (above), then run in a real project.
use regex::Regex;

fn main() {
    let re = Regex::new(r"^\d{3}-\d{4}$").unwrap();

    for candidate in ["555-1234", "hello", "000-0000", "12-3456"] {
        println!("{candidate}: {}", re.is_match(candidate));
    }
}

Notice the pattern is written as a raw string, r"...". Regex syntax leans heavily on backslashes (\d for a digit, \s for whitespace), and Rust’s normal string literals treat backslashes as escape sequences too — so without the r prefix, the compiler tries to interpret \d as an escape character in a Rust string, not as regex syntax, and rejects it outright. r"..." turns backslashes back into plain, literal characters.

.find() and .captures() — with named groups

.is_match() just answers yes/no. .find() returns the matched text itself; .captures() breaks a match into its labeled pieces:

use regex::Regex;

fn main() {
    let re = Regex::new(r"(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})").unwrap();
    let text = "Order placed 2026-08-20, shipped 2026-08-22.";

    for caps in re.captures_iter(text) {
        println!("year={} month={} day={}", &caps["year"], &caps["month"], &caps["day"]);
    }
}

(?<year>\d{4}) names that group year, so you pull it out with &caps["year"] instead of a fragile numeric index like &caps[1]. If you later add or reorder groups in the pattern, code that reads by name keeps working; code that reads by number silently breaks.

The hot-loop trap, and compiling once for real

Regex::new inside a loop — or worse, inside a function called per item — recompiles the same pattern every single time, throwing away the one expensive step over and over. The fix is to compile it once, up front, and pass the compiled Regex around. For a value that needs to live for your whole program without an explicit “pass it everywhere” plumbing job, std::sync::LazyLock builds it lazily on first use and then reuses it:

use regex::Regex;
use std::sync::LazyLock;

static WORD_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\w+").unwrap());

fn count_words(text: &str) -> usize {
    WORD_RE.find_iter(text).count() // same compiled Regex, every call
}

fn main() {
    println!("{}", count_words("the quick brown fox jumps"));
    println!("{}", count_words("regex, compiled exactly once"));
}

WORD_RE compiles its pattern the first time it’s touched, no matter how many times count_words runs afterward — exactly the “compile once, reuse forever” shape you want.

Common mistakes

  • Compiling a Regex inside a loop or a frequently-called function. This dominates the runtime cost of “using regex” — always compile once (module-level LazyLock, or once at startup) and reuse the same Regex value.
  • Forgetting the raw string prefix. Writing "\d{4}" instead of r"\d{4}" fights Rust’s own string escaping, not the regex engine — and it’s a compile error, not a regex error, so read the compiler’s message carefully.
  • Porting a pattern from another language that uses backreferences or lookaround. The regex crate deliberately doesn’t support them, to guarantee linear-time matching (no catastrophic backtracking). A pattern that worked in Python or JS may need rewriting.
  • Reaching for regex when a str method would do. A pattern like "starts with http" doesn’t need Regex::new(r"^http") when s.starts_with("http") says the same thing without a dependency.
  • Reading captures by numeric index. &caps[1] breaks silently if the pattern’s groups are ever reordered; named groups (&caps["name"]) are self-documenting and safer to refactor.

More examples

Extracting a file extension

Splitting on the last . is a fixed, unchanging pattern — rsplit_once('.') reads clearer than a regex and needs no dependency.

fn main() {
    let filenames = ["report.pdf", "archive.tar.gz", "README"];

    for name in filenames {
        match name.rsplit_once('.') {
            Some((_, ext)) => println!("{name}: .{ext}"),
            None => println!("{name}: no extension"),
        }
    }
}

Validating a phone number from a form

A phone field needs a genuine shape check — digits grouped a specific way — which is exactly what a regex is for, unlike a fixed substring check.

use regex::Regex;

fn main() {
    let re = Regex::new(r"^\(\d{3}\) \d{3}-\d{4}$").unwrap();

    for candidate in ["(555) 123-4567", "555-123-4567", "(555) 12-4567"] {
        println!("{candidate}: {}", re.is_match(candidate));
    }
}

Extracting hashtags from a tweet

find_iter walks the whole text and returns every match, not just the first — exactly what pulling out every #hashtag in a post needs.

use regex::Regex;

fn main() {
    let re = Regex::new(r"#\w+").unwrap();
    let tweet = "Loving #rust and #systemsprogramming lately, no #cap.";

    for hashtag in re.find_iter(tweet) {
        println!("{}", hashtag.as_str());
    }
}

Redacting credit card numbers in logs

.replace_all() swaps every match for a fixed string in one pass — handy for scrubbing sensitive numbers out of a log line before it’s written anywhere.

use regex::Regex;

fn main() {
    let re = Regex::new(r"\d{4}-\d{4}-\d{4}-\d{4}").unwrap();
    let log_line = "charged card 4111-1111-1111-1111 for $42.00";

    let redacted = re.replace_all(log_line, "[REDACTED]");
    println!("{redacted}");
}

Your turn

This program is supposed to check whether some text looks like a date — but it doesn’t compile.

use regex::Regex;

fn main() {
    let re = Regex::new("\d{4}-\d{2}-\d{2}").unwrap(); // bug!
    println!("{}", re.is_match("2026-08-20"));
}
Show solution

The pattern is written as a normal Rust string, "\d{4}-\d{2}-\d{2}", not a raw string. Rust tries to interpret \d as a character escape sequence in the string literal itself — and \d isn’t a valid one — so this fails to even compile, with an error like unknown character escape: \d``, well before the regex engine ever sees the pattern.

use regex::Regex;

fn main() {
    let re = Regex::new(r"\d{4}-\d{2}-\d{2}").unwrap(); // r"" = raw string
    println!("{}", re.is_match("2026-08-20"));
}

Adding the r prefix makes it a raw string, where backslashes are just literal characters with no special meaning to Rust — so \d reaches the regex engine exactly as written, where it interprets it as “any digit.” Any regex pattern with backslashes should be written as a raw string as a matter of habit.

Quick check

Remember this

  • Std has no regex — the regex crate is the ecosystem standard, built on a linear-time (non-backtracking) engine.
  • Regex::new(pattern) compiles the pattern once; compiling is the expensive part, so reuse the same Regex instead of recreating it per call or per loop iteration.
  • .is_match() for yes/no, .find() for the matched text, .captures() for pieces — named groups ((?<name>...)) read better than numeric indices.
  • Regex patterns almost always need a raw string (r"...") so backslashes reach the regex engine literally instead of being eaten by Rust’s own string escaping.
  • Reach for plain str methods (split, trim, starts_with, contains) first — regex is for genuine patterns, not fixed substrings.

Go deeper

Next:

Random numbers

Beginner · Runtime & ecosystem

What & why

Games, simulations, sampling, tests that want varied input — plenty of programs need randomness, and Rust’s standard library deliberately has none. Generating good random numbers is a real algorithmic problem (predictable “randomness” is a security bug waiting to happen), so it lives in the rand crate instead of std, where it can evolve independently. rand covers the everyday needs — a random number in a range, picking a random element, shuffling a list — and also lets you pin down a specific, reproducible sequence when that’s what you actually want.

The idea, slowly

A random number in a range

cargo add rand
// rand is an external crate — add it first (above), then run in a real project.
use rand::RngExt;

fn main() {
    let mut rng = rand::rng(); // the default thread-local generator

    let roll: u32 = rng.random_range(1..=6); // inclusive range: 1 through 6
    println!("rolled a {roll}");

    let coin: bool = rng.random();
    println!("heads: {coin}");
}

rand::rng() hands you the default, thread-local random number generator — reach for it first; it’s fast, and good enough for games, sampling, and everyday randomness. .random_range(1..=6) needs the RngExt trait in scope (use rand::RngExt;) because random_range and random are trait methods, not inherent ones. Notice the range is 1..=6, with ..= — an inclusive range, so a real six-sided die can actually roll a 6. A plain 1..6 would only ever produce 1 through 5.

Picking and shuffling — bring IndexedRandom/SliceRandom into scope

Random-ness on a &[T] slice — picking one random element, or shuffling the whole thing in place — lives behind two different traits: rand::seq::IndexedRandom for .choose(), and rand::seq::SliceRandom for .shuffle(). Without both imported, the compiler says those methods simply don’t exist on your slice.

use rand::seq::{IndexedRandom, SliceRandom};

fn main() {
    let mut rng = rand::rng();

    let colors = ["red", "green", "blue", "yellow"];
    if let Some(pick) = colors.choose(&mut rng) {
        println!("picked: {pick}");
    }

    let mut deck: Vec<u32> = (1..=10).collect();
    deck.shuffle(&mut rng);
    println!("shuffled: {deck:?}");
}

.choose(&mut rng) returns Option<&T>None if the slice is empty, since there’s obviously nothing to pick then. .shuffle(&mut rng) reorders the elements in place (hence &mut deck) rather than returning a new collection.

Reproducible sequences with a seeded RNG

The default rand::rng() is intentionally unpredictable — every run gives a different sequence, which is exactly what you want for an actual game. But sometimes you want the opposite: a test that always rolls the same “random” numbers so it’s not flaky, or a simulation you can re-run and get identical output to compare against. For that, seed a specific generator instead of using the default one:

use rand::{RngExt, SeedableRng};
use rand::rngs::StdRng;

fn main() {
    let mut rng = StdRng::seed_from_u64(42);
    let rolls: Vec<u32> = (0..5).map(|_| rng.random_range(1..=6)).collect();
    println!("{rolls:?}"); // identical output every single run
}

StdRng::seed_from_u64(42) builds a generator whose entire sequence is determined by the seed 42 — run this program a hundred times and rolls prints the exact same five numbers every time. Change the seed, get a different (but again fully reproducible) sequence. This is the tool for “I need randomness, but I also need to be able to reproduce a specific run” — tests, simulations, and debugging a bug report that only happens with “unlucky” random input.

Common mistakes

  • Forgetting to import rand::seq::IndexedRandom or SliceRandom. .choose() lives on IndexedRandom, .shuffle() lives on SliceRandom — neither is directly on slices, so without the right one imported the compiler says something like no method named \choose` found for reference `&[…]``.
  • Using a half-open range where you meant inclusive. rng.random_range(1..6) can never produce 6 — for “a die roll from 1 to 6” or “a random index up to and including the last element,” you almost always want ..=.
  • Assuming the default RNG is safe for security-sensitive randomness. rand::rng()’s default algorithm is built for speed and statistical quality, not guaranteed unpredictability against an attacker — check rand’s docs (and consider a CSPRNG) before generating tokens, session ids, or anything security-relevant.
  • Using the default thread RNG in a test that needs to be deterministic. A test built on rand::rng() can pass locally and flake in CI (or vice versa) purely from which random values it happened to draw — seed a StdRng in tests instead.
  • Calling .choose() on a possibly-empty slice and immediately .unwrap()-ing. It returns Option<&T> specifically because an empty slice has nothing to choose — handle the None case, or make sure emptiness is actually impossible first.

More examples

Weighted loot drops in a game

Not every drop should be equally likely — choose_weighted picks an item where higher-weighted entries (like a common sword over a legendary gem) come up more often.

use rand::seq::IndexedRandom;

fn main() {
    let mut rng = rand::rng();

    let loot = [("common sword", 60), ("rare shield", 30), ("legendary gem", 10)];

    if let Ok((item, _weight)) = loot.choose_weighted(&mut rng, |entry| entry.1) {
        println!("dropped: {item}");
    }
}

Generating a random invite code

Sampling random alphanumeric characters and collecting them into a String is the whole recipe for a one-time invite or coupon code.

use rand::RngExt;
use rand::distr::Alphanumeric;

fn main() {
    let rng = rand::rng();

    let invite_code: String = rng
        .sample_iter(&Alphanumeric)
        .take(8)
        .map(char::from)
        .collect();

    println!("your invite code: {invite_code}");
}

Randomly assigning users to an A/B test bucket

random_bool(p) flips a weighted coin — perfect for rolling out a new checkout flow to a fixed percentage of users instead of a plain 50/50 split.

use rand::RngExt;

fn main() {
    let mut rng = rand::rng();

    // 20% of users see the new checkout flow, 80% see the old one.
    let in_new_checkout = rng.random_bool(0.2);
    println!("new checkout flow: {in_new_checkout}");
}

Generating a random accent color

Three independent random_range calls, one per RGB channel, are enough to generate a fresh accent color for a UI theme.

use rand::RngExt;

fn main() {
    let mut rng = rand::rng();

    let (r, g, b): (u8, u8, u8) = (
        rng.random_range(0..=255),
        rng.random_range(0..=255),
        rng.random_range(0..=255),
    );

    println!("accent color: #{r:02X}{g:02X}{b:02X}");
}

Your turn

This program is supposed to pick a random name from a list — but it doesn’t compile.

use rand::Rng;

fn main() {
    let mut rng = rand::rng();
    let names = ["Alice", "Bob", "Chen"];
    let picked = names.choose(&mut rng).unwrap(); // bug!
    println!("chosen: {picked}");
}
Show solution

.choose() is a method from the rand::seq::SliceRandom trait, not something slices have built in — and only rand::Rng is imported here. The compiler rejects this with something like no method named \choose` found for array `[&str; 3]` in the current scope`, and (helpfully) usually suggests the missing trait by name.

use rand::Rng;
use rand::seq::SliceRandom;

fn main() {
    let mut rng = rand::rng();
    let names = ["Alice", "Bob", "Chen"];
    let picked = names.choose(&mut rng).unwrap(); // now SliceRandom is in scope
    println!("chosen: {picked}");
}

Adding use rand::seq::SliceRandom; alongside use rand::Rng; brings .choose() (and .shuffle()) into scope for the slice. Two different jobs — “give me a random number” vs. “do something random with a collection” — live on two different traits, and Rust only gives you the methods for traits you’ve actually imported.

Quick check

Remember this

  • Std has no random number generator — rand is the ecosystem standard.
  • rand::rng().random_range(1..=6) generates a random value in a range; use ..= for an inclusive upper bound (like a real die).
  • .choose(&mut rng) / .shuffle(&mut rng) need rand::seq::SliceRandom imported — without it, the compiler says the methods don’t exist.
  • .choose() returns Option<&T> because an empty slice has nothing to pick.
  • Seed a specific generator (StdRng::seed_from_u64(42)) instead of the default thread RNG when you need a reproducible sequence — tests, simulations, or reproducing a bug exactly.

Go deeper

Next:

Serde and JSON

Intermediate · Runtime & ecosystem

What & why

Serde is the crate the whole Rust world uses to turn structs into JSON (and back). Anytime your program talks to the outside — a web API, a config file, a saved game — it needs to convert between “data as text” and “data as Rust types.” Serde does that conversion for you, safely, from a struct you already wrote.

The idea, slowly

Two words: serialize and deserialize

The name Serde is just “Serialize + Deserialize” smashed together.

  • Serialize = take a Rust value and write it out as text (or bytes). Struct → JSON string.
  • Deserialize = read text back into a Rust value. JSON string → struct.

Think of a struct as a piece of furniture and JSON as the flat-pack box. Serializing is packing the furniture into the box to ship it. Deserializing is opening the box and assembling it again. Serde reads the “shape” of your struct and figures out the packing instructions automatically.

The magic line: #[derive(Serialize, Deserialize)]

You don’t write the packing code by hand. You put one attribute above your struct and Serde generates all of it at compile time:

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Debug)]
struct User {
    id: u64,
    name: String,
}

fn main() {
    let user = User { id: 7, name: String::from("Shamirul") };

    // Rust struct -> JSON text
    let json = serde_json::to_string(&user).unwrap();
    println!("{json}");            // {"id":7,"name":"Shamirul"}

    // JSON text -> Rust struct
    let back: User = serde_json::from_str(&json).unwrap();
    println!("{back:?}");          // User { id: 7, name: "Shamirul" }
}

This needs two external crates, so the Playground’s Run button won’t help here. In a real project, add them to Cargo.toml and run cargo run:

cargo add serde --features derive
cargo add serde_json

Read the flow slowly: to_string takes a reference (&user) and gives back a String of JSON. from_str takes JSON text and — because we annotated the variable as : User — knows what type to build. Both return a Result, because the outside world can always hand you broken data; we’ll .unwrap() here for learning, but real code handles the error.

Why a Result? Because deserializing can fail

Serializing your own struct basically never fails — you control the data. But deserializing reads text from somewhere you don’t trust. If the JSON is missing a field, has the wrong type, or is malformed, from_str returns an Err instead of crashing. That’s Serde protecting you: bad input becomes a value you can handle, not a panic.

Renaming fields to match the outside world

Rust likes snake_case; lots of JSON APIs use camelCase. You bridge the gap with attributes so your Rust stays idiomatic while the wire format stays whatever the API demands:

#![allow(unused)]
fn main() {
use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize)]
struct Product {
    id: u64,
    #[serde(rename = "displayName")]     // JSON says displayName...
    display_name: String,                // ...Rust keeps snake_case
}
}

You can even rename the whole struct’s fields at once with #[serde(rename_all = "camelCase")]. In a real backend like yours (Axum + SeaORM), the entity structs derive Serialize/Deserialize exactly like this so that database rows become API JSON with no hand-written conversion.

Optional and missing fields

The outside world is messy: sometimes a field is there, sometimes it isn’t. Model that with Option<T>. If the JSON has the field, you get Some(value); if it’s absent, you get None instead of an error:

#![allow(unused)]
fn main() {
use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Debug)]
struct Settings {
    theme: String,
    nickname: Option<String>,   // may or may not be present
}
}

This is how you plan for “extra or missing fields” without your program falling over.

Common mistakes

  • Forgetting the derive feature on serde. #[derive(Serialize)] only exists if you add serde with features = ["derive"]. Without it you get a confusing “cannot find derive macro” error even though serde is installed.
  • Field names not matching the JSON. Serde matches by field name. If the API sends displayName and your field is display_name, deserializing fails until you add #[serde(rename = ...)]. It bites because the error appears at runtime, not compile time.
  • Making a field required when the source omits it. A plain String field must be present in the JSON. If the source sometimes drops it, use Option<String> — otherwise every request with that field missing errors out.
  • Calling .unwrap() on from_str in real code. Deserialization handles untrusted input; unwrapping turns a recoverable “bad JSON” into a crash. Handle the Result instead.
  • Forgetting & when serializing. serde_json::to_string(&value) takes a reference; passing the value by move works too but often you still need it afterward, so borrow it.

More examples

Saving app settings as a readable config file

to_string_pretty formats the JSON with indentation and newlines, which matters when the output is a config file a human might open and edit by hand.

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize)]
struct AppConfig {
    theme: String,
    max_connections: u32,
}

fn main() {
    let config = AppConfig { theme: "dark".to_string(), max_connections: 100 };

    let json = serde_json::to_string_pretty(&config).unwrap();
    println!("{json}");
}

Parsing a JSON array from an API response

An API rarely returns just one object — deserializing straight into a Vec<Product> turns a whole JSON array into a ready-to-use list in one call.

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Debug)]
struct Product {
    id: u64,
    name: String,
}

fn main() {
    let response = r#"[{"id":1,"name":"Keyboard"},{"id":2,"name":"Mouse"}]"#;

    let products: Vec<Product> = serde_json::from_str(response).unwrap();
    for p in &products {
        println!("{}: {}", p.id, p.name);
    }
}

Reading a webhook payload with an unpredictable shape

serde_json::Value skips defining a struct entirely — useful for a webhook where different event types carry different fields and you just need to pull out a couple of keys.

use serde_json::Value;

fn main() {
    let payload = r#"{"event":"payment.succeeded","amount":2599,"currency":"usd"}"#;

    let event: Value = serde_json::from_str(payload).unwrap();
    println!("event: {}", event["event"]);
    println!("amount: {}", event["amount"]);
}

Nested structs for an order payload

An order isn’t flat — it has a list of line items — and serde walks nested structs and Vecs automatically, no manual recursion required.

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Debug)]
struct LineItem {
    sku: String,
    quantity: u32,
}

#[derive(Serialize, Deserialize, Debug)]
struct Order {
    order_id: u64,
    items: Vec<LineItem>,
}

fn main() {
    let order = Order {
        order_id: 9001,
        items: vec![
            LineItem { sku: "SKU-1".to_string(), quantity: 2 },
            LineItem { sku: "SKU-2".to_string(), quantity: 1 },
        ],
    };

    let json = serde_json::to_string(&order).unwrap();
    println!("{json}");
}

Your turn

This is a fill-in-the-blank, since serde can’t run on the Playground. This struct should accept JSON where the key is "user_name" and the bio field may be missing entirely. Fix the two blanks.

#![allow(unused)]
fn main() {
use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Debug)]
struct Account {
    // JSON sends "user_name", but we want Rust-style naming here:
    name: String,         // <-- needs an attribute
    bio: String,          // <-- bio is sometimes absent
}
}
Show solution
#![allow(unused)]
fn main() {
use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Debug)]
struct Account {
    #[serde(rename = "user_name")]   // map JSON "user_name" <-> Rust `name`
    name: String,
    bio: Option<String>,             // absent -> None instead of an error
}
}

Why:

  • #[serde(rename = "user_name")] tells Serde the field is called user_name in the JSON, so it stops looking for a key named name and stops failing.
  • Option<String> makes bio optional: present JSON gives Some("..."), missing JSON gives None. Without Option, any JSON lacking bio would fail to deserialize.

Quick check

Remember this

  • Serde = Serialize (Rust → text) + Deserialize (text → Rust).
  • #[derive(Serialize, Deserialize)] generates all the conversion code for you at compile time.
  • Add serde with the derive feature, plus serde_json for JSON: cargo add serde --features derive.
  • serde_json::to_string(&value) and serde_json::from_str(text) both return Result — deserializing untrusted input can fail.
  • Use #[serde(rename = ...)] / rename_all to match outside naming, and Option<T> for fields that may be missing.

Go deeper

Next:

HTTP clients with reqwest

Intermediate · Runtime & ecosystem

What & why

Your program needs data from somewhere else on the internet — a weather API, a GitHub repo’s stats, a payment gateway. That’s an HTTP request: send a URL, get bytes back. reqwest is the crate almost every async Rust project uses to make those requests, and it plugs directly into serde so the response body turns into a real Rust struct in one step. Get it wrong and your program either wastes network connections or silently treats an API’s error message as if it were the data you asked for.

The idea, slowly

One client, reused everywhere

Think of reqwest::Client like a phone line, not a single phone call. Setting one up costs a bit of work — DNS resolution, TLS handshakes, keep-alive connections. Client remembers those connections internally (connection pooling), so the next request to the same host is fast. Build a brand-new Client for every request and you throw that pool away each time, paying the setup cost again and again.

#![allow(unused)]
fn main() {
use reqwest::Client;

async fn run() {
    let client = Client::new(); // build ONCE
    let _ = fetch_repo(&client, "https://api.github.com/repos/rust-lang/rust").await;
    let _ = fetch_repo(&client, "https://api.github.com/repos/tokio-rs/tokio").await;
    // both calls reuse client's connection pool
}
}

In a real app, you build the Client at startup and pass a reference to it — or clone it, since Client is cheap to clone (it’s an Arc under the hood) — into whatever code needs to make requests.

Making a request and deserializing the body

reqwest is async, so every call needs .await. The typical shape is: .get(url) builds the request, .send().await? actually performs it and hands back a Response, and .json::<T>().await? reads the body and deserializes it into your own serde type — no manual string parsing.

#![allow(unused)]
fn main() {
use serde::Deserialize;

#[derive(Deserialize, Debug)]
struct Repo {
    name: String,
    stargazers_count: u32,
}

async fn fetch_repo(client: &reqwest::Client, url: &str) -> reqwest::Result<Repo> {
    let response = client.get(url).send().await?;
    let repo = response.json::<Repo>().await?;
    Ok(repo)
}
}

Read that slowly: send() returns a Result<Response, Error>, so ? unwraps it or bubbles the error up. json::<Repo>() is where serde does its work — it reads the body text as JSON and builds a Repo from it, also returning a Result because the body might not match the shape you expect.

This needs two external crates plus an async runtime, so the Playground can’t run it. In a real project:

cargo add reqwest --features json
cargo add serde --features derive
cargo add tokio --features full

A non-2xx status is NOT automatically an error

This is the trap that catches almost everyone the first time. .send().await only returns Err for things like “couldn’t reach the server” or “connection reset” — genuine network failures. A server that responds with 404 Not Found or 500 Internal Server Error still counts as a successful HTTP exchange as far as reqwest is concerned: you got a response, it just carries a status code you might not like.

#![allow(unused)]
fn main() {
async fn fetch_status(client: &reqwest::Client, url: &str) -> reqwest::Result<()> {
    let response = client.get(url).send().await?; // Ok even for a 404!

    if response.status().is_success() {
        println!("got it: {}", response.text().await?);
    } else {
        println!("server said no: {}", response.status());
    }
    Ok(())
}
}

You have two options: check .status() yourself (as above), or call .error_for_status() on the response, which turns a non-2xx status into an Err for you so ? can propagate it like any other failure:

#![allow(unused)]
fn main() {
async fn fetch_ok(client: &reqwest::Client, url: &str) -> reqwest::Result<String> {
    let response = client.get(url).send().await?.error_for_status()?; // 4xx/5xx -> Err here
    response.text().await
}
}

Setting a timeout

An HTTP call with no timeout can, worst case, hang forever if the server never responds. Always set one, either per-client (applies to every request made through it) or per-request:

#![allow(unused)]
fn main() {
use std::time::Duration;

fn build_client() -> reqwest::Result<reqwest::Client> {
    reqwest::Client::builder()
        .timeout(Duration::from_secs(10))
        .build()
}
}

Now any request through that client that takes longer than 10 seconds fails with a timeout Error instead of hanging your program.

Common mistakes

  • Building a new Client per request. You lose connection pooling and pay setup costs repeatedly. Build one Client at startup and reuse it — it’s cheap to .clone().
  • Assuming a 404/500 is automatically an Err. It isn’t — .send().await? only errors on network-level failures. Check .status() or call .error_for_status() to treat bad status codes as errors.
  • Calling .json::<T>() on an error response. If the server’s error body doesn’t match your success struct’s shape, deserializing fails with a confusing “missing field” error instead of the real problem (a 404). Check the status before parsing the body as your success type.
  • No timeout set. A slow or dead server can hang your request indefinitely. Set .timeout(...) on the client or the request.
  • Forgetting .await. Every reqwest call here is async; a missing .await gives you a Future that never runs, not the value you wanted — the compiler flags the type mismatch.

More examples

Placing an order in a checkout flow

Creating something on a server, not just reading it, means POSTing a body — .json(&order) serializes the struct straight into the request, and .json::<OrderConfirmation>() deserializes what comes back.

#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};

#[derive(Serialize)]
struct NewOrder {
    sku: String,
    quantity: u32,
}

#[derive(Deserialize, Debug)]
struct OrderConfirmation {
    id: u64,
    status: String,
}

async fn place_order(client: &reqwest::Client) -> reqwest::Result<OrderConfirmation> {
    let order = NewOrder { sku: "sku-42".to_string(), quantity: 3 };

    let confirmation = client
        .post("https://api.example.com/orders")
        .json(&order)
        .send()
        .await?
        .error_for_status()?
        .json::<OrderConfirmation>()
        .await?;

    Ok(confirmation)
}
}

Authenticating against a private API

Most real APIs won’t answer without proof of who’s asking — .header("Authorization", ...) attaches a bearer token to the request the same way a browser would.

#![allow(unused)]
fn main() {
async fn fetch_private_repo(client: &reqwest::Client, token: &str) -> reqwest::Result<String> {
    let body = client
        .get("https://api.github.com/user/repos")
        .header("Authorization", format!("Bearer {token}"))
        .send()
        .await?
        .error_for_status()?
        .text()
        .await?;

    Ok(body)
}
}

Downloading a user’s avatar image

Not every response is text — an avatar upload endpoint needs the raw bytes untouched, so .bytes() skips the JSON/text parsing entirely and hands back the file as-is.

#![allow(unused)]
fn main() {
async fn download_avatar(client: &reqwest::Client, url: &str) -> reqwest::Result<Vec<u8>> {
    let bytes = client
        .get(url)
        .send()
        .await?
        .error_for_status()?
        .bytes()
        .await?;

    Ok(bytes.to_vec())
}
}

Cleaning up a stale session on logout

A logout button doesn’t need a response body back — just confirmation the server did it — so a DELETE call that discards everything but the status code is enough.

#![allow(unused)]
fn main() {
async fn delete_session(client: &reqwest::Client, session_id: &str) -> reqwest::Result<()> {
    let url = format!("https://api.example.com/sessions/{session_id}");

    client.delete(&url).send().await?.error_for_status()?;

    Ok(())
}
}

Your turn

This function fetches a user profile by id. When the user doesn’t exist, the API responds 404 Not Found with a body like {"error": "not found"} — but the code below never checks the status before trying to parse a User out of it.

#![allow(unused)]
fn main() {
use serde::Deserialize;

#[derive(Deserialize, Debug)]
struct User {
    id: u64,
    name: String,
}

async fn get_user(client: &reqwest::Client, id: u64) -> Result<User, reqwest::Error> {
    let url = format!("https://api.example.com/users/{id}");
    let response = client.get(&url).send().await?;
    let user = response.json::<User>().await?; // uh oh
    Ok(user)
}
}

What happens when id doesn’t exist? send().await? succeeds (a 404 is still a valid HTTP response), so execution reaches .json::<User>(). That tries to deserialize {"error": "not found"} into a User, which has no id or name fields in that body — you get a baffling missing field 'id' deserialize error instead of a clear “user not found.”

Show solution

Check the status before parsing the body as a User.error_for_status() is the one-line fix:

#![allow(unused)]
fn main() {
use serde::Deserialize;

#[derive(Deserialize, Debug)]
struct User {
    id: u64,
    name: String,
}

async fn get_user(client: &reqwest::Client, id: u64) -> Result<User, reqwest::Error> {
    let url = format!("https://api.example.com/users/{id}");
    let response = client
        .get(&url)
        .send()
        .await?
        .error_for_status()?; // 404/500/etc become a real Err here
    let user = response.json::<User>().await?;
    Ok(user)
}
}

Now a 404 returns a reqwest::Error describing the bad status, right where it happened — instead of a confusing deserialize failure two steps later. The caller sees “request failed with status 404,” not “missing field.”

Quick check

Remember this

  • Build one reqwest::Client and reuse it across requests — it internally pools connections; a new client per request throws that pooling away.
  • .get(url).send().await? performs the request; .json::<MyType>().await? deserializes the body via serde in the same chain.
  • A non-2xx response is not automatically an Err — check .status() or call .error_for_status() before trusting the body matches your success type.
  • Always set a timeout (.timeout(Duration::from_secs(10)) on the client or request) — an unbounded HTTP call can hang forever.
  • reqwest::Client is cheap to .clone() (it’s an Arc internally), so sharing it across tasks/handlers is normal.

Go deeper

Next:

Databases with sqlx

Intermediate · Runtime & ecosystem

What & why

Almost every real backend needs to talk to a database. sqlx is Rust’s async SQL toolkit for Postgres, MySQL, and SQLite — but unlike a traditional ORM, it doesn’t hide SQL behind a chain of .where().order_by() method calls. You write real SQL strings, and sqlx compiles them against your actual database schema so a typo in a column name is a compile error, not a bug that only shows up in production.

The idea, slowly

Not an ORM — SQL you write yourself, checked for you

An ORM (object-relational mapper) tries to make the database disappear behind Rust method calls. sqlx takes the opposite approach: you write the SQL, and it earns your trust by checking that SQL against the real database — the columns exist, the types line up, the query is syntactically valid Postgres/MySQL/SQLite.

#![allow(unused)]
fn main() {
use sqlx::PgPool;

#[derive(sqlx::FromRow)]
struct User {
    id: i64,
    name: String,
}

async fn get_user(pool: &PgPool, id: i64) -> sqlx::Result<User> {
    sqlx::query_as::<_, User>("SELECT id, name FROM users WHERE id = $1")
        .bind(id)
        .fetch_one(pool)
        .await
}
}

query_as::<_, User>(...) says “run this SQL and map each row into a User.” .bind(id) fills in the $1 placeholder safely. .fetch_one runs it and expects exactly one row back.

query! and query_as!: checked against a LIVE database at compile time

The macro versions — sqlx::query! and sqlx::query_as! — go one step further than the function calls above. While you run cargo build, sqlx actually connects to a real database (via a DATABASE_URL environment variable, or a saved offline cache) and asks it: “is this SQL valid? What columns and types does this query return?” It then generates code that matches — so a typo like SELCT or a renamed column fails your build, not a customer’s request.

#![allow(unused)]
fn main() {
async fn get_user_checked(pool: &sqlx::PgPool, id: i64) -> sqlx::Result<(i64, String)> {
    let row = sqlx::query!("SELECT id, name FROM users WHERE id = $1", id)
        .fetch_one(pool)
        .await?;
    Ok((row.id, row.name))
}
}

This is the headline feature: your database schema effectively becomes part of the type system while you’re building.

One pool, created once, shared everywhere

Opening a database connection is expensive — a TCP handshake, authentication, session setup. You don’t want to pay that cost on every query. sqlx::PgPool is a pool of already-open connections that your whole app shares: create it once at startup, then hand a reference (or Arc<PgPool>) to every part of your code that needs the database.

#![allow(unused)]
fn main() {
async fn start_app() -> sqlx::Result<()> {
    let pool = sqlx::PgPool::connect("postgres://user:pass@localhost/mydb").await?;

    // pass &pool (or clone it — PgPool clones cheaply, it's a handle to the pool)
    let user = get_user(&pool, 1).await?;
    println!("{}", user.name);
    Ok(())
}
}

PgPool is cheap to .clone() — cloning it doesn’t open new connections, it just hands out another handle to the same shared pool.

cargo add sqlx --features runtime-tokio,postgres,macros
cargo add tokio --features full

Bound parameters, not string formatting

$1, $2, … are placeholders. You give sqlx the value separately via .bind(...) (or as extra arguments to query!), and the database driver sends the query text and the values as separate pieces — never mashed together into one string. This is what makes SQL injection basically impossible if you stick to it: user input is always data, never part of the SQL syntax.

#![allow(unused)]
fn main() {
// good: name travels as data, never as SQL text
sqlx::query_as::<_, User>("SELECT id, name FROM users WHERE name = $1")
    .bind(name)
    .fetch_optional(pool)
    .await
}

Format a variable straight into the SQL string instead, and you’ve reopened the exact hole parameterized queries exist to close.

Common mistakes

  • String-formatting values into SQL. format!("... WHERE name = '{}'", name) reintroduces SQL injection and throws away sqlx’s whole safety story. Always use $1, $2, … with .bind(...).
  • Opening a new connection per query instead of sharing a pool. Each connection is expensive to set up; under real traffic this exhausts the database’s connection limit fast. Create one PgPool at startup and share it.
  • No live database (or offline cache) at build time. The query!/query_as! macros need to reach a real database (via DATABASE_URL) or a saved .sqlx offline cache to check your SQL while compiling. CI pipelines that forget this get a build failure with “set DATABASE_URL” even though the SQL is fine.
  • Treating sqlx like an ORM. There’s no .where()/.order_by() chain — you write the SQL yourself. Trying to build queries piece-by-piece in Rust fights the library instead of using it.
  • Mismatched Rust/SQL types. A NULL-able SQL column mapped to a non-Option Rust field is a compile error with query! (which is the point — it caught a real mismatch), not a bug to silence.

More examples

Creating a post and getting its id back

Inserting a row often isn’t enough by itself — a RETURNING id clause hands back the database-generated primary key in the same round trip.

#![allow(unused)]
fn main() {
use sqlx::PgPool;

async fn create_post(pool: &PgPool, title: &str) -> sqlx::Result<i64> {
    sqlx::query_scalar("INSERT INTO posts (title) VALUES ($1) RETURNING id")
        .bind(title)
        .fetch_one(pool)
        .await
}
}

Updating a user’s email address

An account-settings form’s “save” button is just an UPDATE with two bound parameters — the row to change and the new value, never raw SQL text.

#![allow(unused)]
fn main() {
use sqlx::PgPool;

async fn update_email(pool: &PgPool, user_id: i64, new_email: &str) -> sqlx::Result<()> {
    sqlx::query("UPDATE users SET email = $1 WHERE id = $2")
        .bind(new_email)
        .bind(user_id)
        .execute(pool)
        .await?;
    Ok(())
}
}

Transferring money without leaving an account half-updated

Moving money between two accounts has to be all-or-nothing — a transaction makes sure a crash between the two updates can’t leave one account debited and the other never credited.

#![allow(unused)]
fn main() {
use sqlx::PgPool;

async fn transfer_funds(
    pool: &PgPool,
    from_account: i64,
    to_account: i64,
    amount_cents: i64,
) -> sqlx::Result<()> {
    let mut tx = pool.begin().await?;

    sqlx::query("UPDATE accounts SET balance_cents = balance_cents - $1 WHERE id = $2")
        .bind(amount_cents)
        .bind(from_account)
        .execute(&mut *tx)
        .await?;

    sqlx::query("UPDATE accounts SET balance_cents = balance_cents + $1 WHERE id = $2")
        .bind(amount_cents)
        .bind(to_account)
        .execute(&mut *tx)
        .await?;

    tx.commit().await
}
}

Paginating a blog’s list of posts

A blog’s archive page can’t load every post at once — LIMIT and OFFSET, bound the same way as any other value, fetch just the page the reader asked for.

#![allow(unused)]
fn main() {
use sqlx::PgPool;

#[derive(sqlx::FromRow)]
struct Post {
    id: i64,
    title: String,
}

async fn list_posts_page(pool: &PgPool, page: i64, page_size: i64) -> sqlx::Result<Vec<Post>> {
    sqlx::query_as::<_, Post>("SELECT id, title FROM posts ORDER BY id LIMIT $1 OFFSET $2")
        .bind(page_size)
        .bind(page * page_size)
        .fetch_all(pool)
        .await
}
}

Your turn

This function looks up a user by name — but it builds the SQL by formatting name straight into the query string instead of using a bound parameter.

#![allow(unused)]
fn main() {
use sqlx::PgPool;

#[derive(sqlx::FromRow)]
struct User {
    id: i64,
    name: String,
}

async fn find_user_by_name(pool: &PgPool, name: &str) -> sqlx::Result<Option<User>> {
    let query = format!("SELECT id, name FROM users WHERE name = '{}'", name);
    sqlx::query_as::<_, User>(&query)
        .fetch_optional(pool)
        .await
}
}

What’s wrong? If someone calls find_user_by_name(&pool, "x' OR '1'='1"), the formatted string becomes SELECT id, name FROM users WHERE name = 'x' OR '1'='1' — the attacker’s input escaped the quotes and rewrote the query’s logic to match every row. That’s a SQL injection vulnerability, and it also throws away everything sqlx offers for query safety.

Show solution

Use a bound parameter ($1) and .bind(name) instead of formatting the string:

#![allow(unused)]
fn main() {
use sqlx::PgPool;

#[derive(sqlx::FromRow)]
struct User {
    id: i64,
    name: String,
}

async fn find_user_by_name(pool: &PgPool, name: &str) -> sqlx::Result<Option<User>> {
    sqlx::query_as::<_, User>("SELECT id, name FROM users WHERE name = $1")
        .bind(name)
        .fetch_optional(pool)
        .await
}
}

Now name always travels to the database as a value, never as part of the SQL text — the driver sends the query shape and the data separately, so there’s no string for an attacker to “escape out of.” This also means the query text is a fixed &'static str that sqlx can check once, instead of a different string on every call.

Quick check

Remember this

  • sqlx isn’t an ORM — you write real SQL, and it checks that SQL for you instead of hiding it.
  • sqlx::query!/sqlx::query_as! connect to a live database at compile time (or use a saved offline cache) to verify your SQL and infer result types.
  • Create one PgPool (PgPool::connect(...).await?) at startup and share it (e.g. via Arc or app state) — don’t open a new connection per query.
  • Always use bound parameters ($1, $2, …) with .bind(...) instead of formatting SQL strings by hand — that’s what makes SQL injection basically impossible.
  • PgPool is cheap to .clone() — it’s a handle to the shared pool, not a new set of connections.

Go deeper

Next:

CLI apps

Intermediate · Runtime & ecosystem

What & why

A CLI (command-line interface) app is a program you run in the terminal by typing its name, maybe with some options, like git commit -m "hi". Rust is a great fit for these: they start instantly, ship as a single file, and never crash from a missing runtime. This lesson covers the three things every CLI needs — reading arguments, printing to the right place, and exiting with the right code.

The idea, slowly

What is an “argument,” really?

When you type myapp hello --loud in a terminal, the shell hands your program a list of words: ["myapp", "hello", "--loud"]. The first word is always the program’s own name. Everything after is input you have to make sense of. Rust gives you that list through std::env::args():

fn main() {
    // Collect the arguments into a vector of Strings.
    let args: Vec<String> = std::env::args().collect();
    println!("{args:?}");
    println!("You passed {} argument(s) (including the program name).", args.len());
}

Run this on the Playground and you’ll see just the program name, because the Playground runs with no extra arguments. In a real project, cargo run -- hello --loud would show all three. (The -- tells cargo “everything after this belongs to my program, not to cargo.”)

Reading a specific argument

args[0] is the program name, so the first real argument is args[1]. But what if the user forgot to pass it? Indexing args[1] when it doesn’t exist would panic. The safe way is .get(1), which returns an Option:

fn main() {
    let args: Vec<String> = std::env::args().collect();

    // .get(1) is safe: Some(value) if it exists, None if it doesn't.
    match args.get(1) {
        Some(name) => println!("Hello, {name}!"),
        None => println!("Usage: greet <name>"),
    }
}

This runs on the Playground (it just prints the usage line, since there’s no argument). The lesson: never assume the user gave you input. .get() + match turns “missing argument” into a polite message instead of a crash.

stdout vs stderr: two separate pipes

Your terminal actually has two output streams:

  • stdout (“standard out”) — the program’s real result. The data. The answer.
  • stderr (“standard error”) — messages about the run: progress, warnings, errors.

Why two? Because people pipe programs together. If someone runs myapp > results.txt, only stdout goes into the file; stderr still shows on screen. If you print your error messages to stdout, they get mixed into results.txt and ruin the data. So the rule is: real output to stdout, everything else to stderr.

fn main() {
    // println! writes to stdout — the actual result.
    println!("42");

    // eprintln! writes to stderr — status and errors.
    eprintln!("done computing");
}

println! = stdout. eprintln! (note the extra e) = stderr. That one letter is the whole difference.

Exit codes: telling the shell if you succeeded

When a program finishes, it returns a small number to the shell. 0 means success; anything else means failure. Other tools and scripts rely on this — myapp && echo ok only prints ok if myapp exited 0. You set it with std::process::exit:

fn main() {
    let ok = false;
    if !ok {
        eprintln!("error: something went wrong");
        std::process::exit(1);   // non-zero = failure
    }
    println!("all good");
}

A tidier alternative: make main return Result<(), E>. If it returns Ok, Rust exits 0; if it returns Err, Rust prints the error to stderr and exits non-zero for you.

When to reach for clap

Parsing --flags and --options=values by hand gets painful fast. For anything beyond a couple of arguments, the ecosystem standard is clap. You describe your arguments as a struct with attributes, and clap generates the parser, the --help text, and the error messages:

use clap::Parser;

#[derive(Parser)]
#[command(about = "Greets a person")]
struct Cli {
    /// Who to greet
    name: String,

    /// Say it loudly
    #[arg(long)]
    loud: bool,
}

fn main() {
    let cli = Cli::parse();
    let greeting = format!("Hello, {}!", cli.name);
    if cli.loud {
        println!("{}", greeting.to_uppercase());
    } else {
        println!("{greeting}");
    }
}

clap is an external crate, so this won’t run on the Playground. In a real project, add it and run it:

cargo add clap --features derive
cargo run -- Shamirul --loud

You get --help, --version, and friendly “missing argument” errors for free — that’s the whole reason clap exists.

Common mistakes

  • Indexing args[1] directly. If the user didn’t pass that argument, the program panics with an ugly backtrace. Use .get(1) and handle the None case with a usage message.
  • Printing errors to stdout. They get mixed into piped/redirected output and corrupt the real result. Send status and errors to stderr with eprintln!.
  • Always exiting 0. If your program fails but returns 0, scripts think it succeeded and keep going. Exit non-zero on failure (or return Err from main).
  • Hand-parsing complex flags. Rolling your own --option parser is bug-prone and gives users no --help. Use clap once you have more than one or two arguments.
  • Forgetting the -- with cargo run. cargo run hello passes hello to cargo; you need cargo run -- hello to pass it to your program.

More examples

Checking every file a linter was pointed at

A linter that accepts any number of filenames on the command line just needs everything in args after the program name — &args[1..] turns the rest of the list into the files to check.

fn main() {
    let args: Vec<String> = std::env::args().collect();
    let files = &args[1..]; // everything after the program name

    println!("checking {} file(s)", files.len());
    for f in files {
        println!("  - {f}");
    }
}

Keeping backup progress separate from its result

A backup tool that prints progress to stdout ruins itself the moment someone captures its output with $(...) — status goes to stderr, and the one line that matters goes to stdout.

fn main() {
    eprintln!("backing up 3 files...");
    eprintln!("backing up 12 files...");

    // the actual result: a path a caller could capture with `$(myapp backup)`
    println!("/backups/2026-08-31.tar.gz");
}

Rejecting a bad port number with the right exit code

A port-checker CLI needs scripts to be able to tell “you gave me garbage” apart from “the port is closed” — exiting 2 for a usage error keeps that distinction visible to anything calling it.

fn main() {
    let args: Vec<String> = std::env::args().collect();

    let port: u16 = match args.get(1) {
        Some(p) => match p.parse() {
            Ok(n) => n,
            Err(_) => {
                eprintln!("error: '{p}' is not a valid port number");
                std::process::exit(2); // usage error
            }
        },
        None => {
            eprintln!("usage: portcheck <port>");
            std::process::exit(2);
        }
    };

    println!("checking port {port}...");
}

A clap-powered to-do CLI

clap turns a to-do tool’s task argument and --urgent flag into a real struct — no hand-written parsing, and --help for free.

use clap::Parser;

#[derive(Parser)]
#[command(about = "Adds a task to your to-do list")]
struct Cli {
    /// What needs doing
    task: String,

    /// Mark it urgent
    #[arg(long)]
    urgent: bool,
}

fn main() {
    let cli = Cli::parse();
    if cli.urgent {
        println!("[URGENT] {}", cli.task);
    } else {
        println!("added: {}", cli.task);
    }
}

Your turn

This program should greet the argument the user passed, but it crashes when run with no argument. Fix it so that with no argument it prints Usage: greet <name> instead of panicking.

fn main() {
    let args: Vec<String> = std::env::args().collect();
    let name = &args[1];               // panics if there is no args[1]
    println!("Hello, {name}!");
}
Show solution

Use .get(1) so a missing argument becomes None instead of a panic, and print the usage line to stderr:

fn main() {
    let args: Vec<String> = std::env::args().collect();

    match args.get(1) {
        Some(name) => println!("Hello, {name}!"),
        None => {
            eprintln!("Usage: greet <name>");
            std::process::exit(1);       // non-zero: we failed to do the job
        }
    }
}

args[1] panics the instant the index is out of range. .get(1) returns an Option, so “no argument” is just None — a case you handle calmly. Sending the usage message to stderr and exiting 1 also tells any calling script that this run didn’t succeed.

Quick check

Remember this

  • std::env::args() gives the argument list; args[0] is the program name, real arguments start at args[1].
  • Use .get(1) (not args[1]) so a missing argument is None, not a panic.
  • stdout (println!) is for real output; stderr (eprintln!) is for status and errors — keep them separate.
  • Exit 0 for success, non-zero for failure; or return Result from main and let Rust do it.
  • For anything beyond a couple of arguments, use clap — it generates the parser and --help for you.

Go deeper

Next:

Logging and tracing

Intermediate · Runtime & ecosystem

What & why

Logging is how your program tells you what it’s doing while it runs — so that when something breaks at 2am in production, you have a trail to follow instead of a shrug. Tracing is logging’s grown-up sibling: it adds structure (named fields) and spans (the story of one request from start to finish). This lesson shows why println! isn’t enough and how the tracing crate fixes it.

The idea, slowly

Why not just use println!?

When you’re learning, println!("here") is a fine way to peek at what’s happening. But in a real program it falls apart:

  • You can’t turn it off without deleting lines. In production you want less noise; while debugging you want more. println! is all-or-nothing.
  • There are no levels — no way to say “this is just info” versus “this is a real error.”
  • It goes to stdout, mixing debug spew into your program’s real output.
  • There’s no timestamp, no context, no way to filter.

Logging libraries fix all of that. You write a log line once, tag it with a level, and later decide — without touching the code — how much of it you actually want to see.

The five levels

Every logging system has severity levels, from noisiest to most serious:

  • trace — extremely fine-grained “I am here” detail.
  • debug — information useful while developing.
  • info — normal, noteworthy events (“server started”, “user logged in”).
  • warn — something looks off but the program continues.
  • error — something actually failed.

You set a threshold (say, info) and everything below it is silently dropped. In development you lower the threshold to debug to see more; in production you raise it to keep logs quiet and cheap.

The tracing crate: macros per level

The modern Rust standard is the tracing crate. You emit a log with a macro named after the level. The key upgrade over println! is structured fields — named key/value pairs, not just a sentence:

use tracing::{info, warn, error};

fn main() {
    // You MUST install a subscriber first, or nothing prints (see below).
    tracing_subscriber::fmt::init();

    let user_id = 7;

    info!("server started");
    // The `user_id` before the message becomes a structured field, not just text:
    info!(user_id, "user logged in");
    warn!(retries = 3, "slow database response");
    error!("failed to connect to database");
}

This uses external crates, so it won’t run on the Playground. In a real project:

cargo add tracing
cargo add tracing-subscriber
cargo run

Notice info!(user_id, "user logged in"). The user_id isn’t glued into the sentence — it’s attached as a labeled field. Later a log tool can search “show me every event where user_id = 7,” which is impossible when the value is buried inside a string.

The subscriber: someone has to be listening

Here’s the part that trips everyone up. In tracing, the macros (info!, error!) only emit events. They don’t decide where the events go — that’s a separate piece called a subscriber. If you never install one, your log calls do nothing. It’s like a radio station broadcasting with no receiver switched on.

The simplest receiver is tracing_subscriber::fmt::init(), which prints formatted logs to the terminal. You call it once, at the very start of main, before any logging happens. In your real Axum backend, this is exactly what the init_logger() function does — it sets up a subscriber with an EnvFilter so the log level can be controlled from an environment variable:

#![allow(unused)]
fn main() {
use tracing_subscriber::{fmt, EnvFilter};

fn init_logger() {
    // Read the level from the RUST_LOG env var, defaulting to "info".
    let filter = EnvFilter::try_from_default_env()
        .unwrap_or_else(|_| EnvFilter::new("info"));
    fmt().with_env_filter(filter).init();
}
}

Now RUST_LOG=debug cargo run shows debug logs; plain cargo run shows only info and above — no code change needed.

Spans: the story of one request

info! gives you single events, like snapshots. A span gives you a duration — it wraps a chunk of work so every log inside it is automatically tagged with that context. In a web server, you open a span per request; then every log line during that request carries the request’s id, so you can follow one user’s journey even when a thousand requests are interleaved:

#![allow(unused)]
fn main() {
use tracing::info_span;

fn handle_request(id: u64) {
    // Everything logged while this span is entered is tagged with request_id.
    let span = info_span!("request", request_id = id);
    let _guard = span.enter();

    tracing::info!("handling");   // automatically carries request_id = id
}
}

That’s the real difference between “logging” and “tracing”: events are dots, spans connect the dots into a line.

Common mistakes

  • Forgetting to install a subscriber. Your info!/error! calls compile and run but print nothing, because no receiver is listening. Call tracing_subscriber::fmt::init() (or your init_logger) once at startup.
  • Logging secrets. Passwords, tokens, API keys, full credit-card numbers — logs are often stored and shared widely, so anything sensitive in them is a leak. Redact before logging.
  • Using the wrong level. Logging routine events at error cries wolf; logging real failures at debug hides them. The level is the signal — pick it deliberately.
  • Gluing values into the message instead of using fields. info!("user {id}") makes the id unsearchable text; info!(user_id = id, "user") makes it a queryable field. Prefer fields.
  • Mixing logs into stdout output. For a CLI, send logs to stderr so they don’t corrupt the program’s real stdout result (see the CLI lesson).

More examples

Order checkout events

An e-commerce checkout can attach the order id and total as structured fields, so later you can query “every event where order_id = 4821” instead of grepping a sentence.

use tracing::info;

fn main() {
    tracing_subscriber::fmt::init();

    let order_id = 4821;
    let total = 59.97;

    info!(order_id, total, "order placed");
}

A span per background job

A job queue worker wraps each job in a span, so every log line it emits while running — start, progress, finish — is automatically tagged with that job’s id, even with many workers running at once.

use tracing::{info, info_span};

fn main() {
    tracing_subscriber::fmt::init();

    let job_id = 17;
    let span = info_span!("job", job_id);
    let _guard = span.enter();

    info!("processing started");
    info!(rows_processed = 240, "processing finished");
}

Flagging repeated login failures

An auth service can warn on suspicious activity — like a username and attempt count — without ever logging the password itself.

use tracing::warn;

fn main() {
    tracing_subscriber::fmt::init();

    let username = "shaon";
    let attempt = 3;

    warn!(username, attempt, "failed login attempt");
}

Skipping bad rows during a CSV import

A batch importer can log each row at debug and escalate only the rows that fail to parse to error, so a normal run stays quiet and a broken one points straight at the bad data.

use tracing::{debug, error};

fn main() {
    tracing_subscriber::fmt::init();

    let rows = ["1,Alice", "bad-row", "3,Carol"];

    for (i, row) in rows.iter().enumerate() {
        if row.split(',').count() != 2 {
            error!(row_number = i, row, "skipping malformed row");
        } else {
            debug!(row_number = i, "row parsed");
        }
    }
}

Your turn

This is a spot-the-bug, since tracing can’t run on the Playground. A beginner runs this and complains “my logs never appear!” What did they forget, and why does that cause total silence?

use tracing::info;

fn main() {
    // ... no setup here ...
    info!("app started");
    info!(items = 3, "loaded items");
}
Show solution

They never installed a subscriber, so nobody is listening to the events. The macros run but have nowhere to send their output.

use tracing::info;

fn main() {
    tracing_subscriber::fmt::init();   // <-- the missing receiver

    info!("app started");
    info!(items = 3, "loaded items");
}

In tracing, the info!/warn!/error! macros only emit events. A separate subscriber decides where they go and whether to print them. With no subscriber installed, every log call silently does nothing. Installing tracing_subscriber::fmt::init() once at the top of main gives the events a home — the terminal.

Quick check

Remember this

  • println! doesn’t scale for real programs — no levels, no filtering, no structure.
  • Levels from noisiest to most serious: trace, debug, info, warn, error; set a threshold and everything below is dropped.
  • The tracing macros only emit events — you must install a subscriber (e.g. tracing_subscriber::fmt::init()) or nothing prints.
  • Prefer structured fields (info!(user_id, "...")) over stuffing values into the message string — fields are searchable.
  • Spans tag every log inside them with shared context, so you can follow one request end to end.
  • Never log secrets, and pick the level that matches the real severity.

Go deeper

Next:

cfg and Cargo feature flags

Intermediate · Runtime & ecosystem

What & why

#[cfg(...)] is an “if” that the compiler evaluates before your program exists — code that doesn’t match gets thrown away at compile time, not skipped at runtime. Cargo features are the switches you flip to control those conditions: they let one crate offer optional functionality (and optional dependencies) that stays out of the binary unless someone asks for it. Together they’re how a crate stays small and portable by default while still supporting Linux-only code, test-only helpers, or a heavy dependency nobody should pay for unless they use it.

The idea, slowly

#[cfg(...)]: an if-statement for the compiler

Put #[cfg(condition)] above an item (a function, a struct, a module, even a single line inside a function) and the compiler only keeps that item if the condition is true for this build. If it’s false, the item isn’t compiled — it’s as if you deleted it, not as if you wrapped it in if false.

fn main() {
    #[cfg(target_os = "linux")]
    println!("hello from linux");

    #[cfg(target_os = "macos")]
    println!("hello from macos");

    #[cfg(target_os = "windows")]
    println!("hello from windows");

    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
    println!("hello from some other OS");
}

Run this and exactly one line prints — the other three branches never made it into the compiled binary for your platform. Two other conditions come up constantly:

  • #[cfg(test)] — true only when compiling with cargo test. This is how test modules stay out of your normal binary entirely.
  • #[cfg(debug_assertions)] — true in a normal cargo build/cargo run, false in cargo build --release. Handy for extra checks or verbose output you only want during development.
fn main() {
    #[cfg(debug_assertions)]
    println!("running a debug build (cargo run)");

    #[cfg(not(debug_assertions))]
    println!("running a release build (cargo run --release)");
}

Declaring a Cargo feature

#[cfg(target_os = "...")] reacts to where you’re compiling. Cargo features let you react to what someone asked for. You declare them in a [features] table in Cargo.toml:

[package]
name = "mytool"
version = "0.1.0"
edition = "2021"

[features]
json = []

That alone doesn’t do anything by itself — it just gives the name json a meaning Cargo understands. Someone builds with it on via cargo build --features json, or a default feature list turns it on automatically:

[features]
default = ["json"]
json = []

Checking a feature in code

Once a feature exists, #[cfg(feature = "json")] works exactly like #[cfg(target_os = "...")] — the item is compiled in only when that feature is enabled for this build:

#![allow(unused)]
fn main() {
#[cfg(feature = "json")]
pub fn to_json(value: &str) -> String {
    format!("\"{value}\"")
}
}

With json off, to_json doesn’t exist in the compiled crate at all — calling it from elsewhere is a “function not found” error, not a runtime failure.

Making a heavy dependency opt-in

The most common real use of features: a crate wants to support something like JSON output, but doesn’t want to force every user to pull in serde_json if they never use it. The pattern is optional = true on the dependency plus a feature of the same name that turns it on:

[dependencies]
serde_json = { version = "1", optional = true }

[features]
json = ["dep:serde_json"]

optional = true means “don’t compile this dependency in unless something enables it.” The dep:serde_json syntax in the feature list is what actually enables it — it says “turning on json also turns on the serde_json dependency.” Now serde_json is compiled and linked only for people who opt into json, and everyone else’s build stays smaller and faster to compile.

Features are additive — never conflicting

Here’s the rule that matters once your crate has dependents: features are additive across the entire dependency graph. If your crate and someone else’s dependency both depend on serde_json — and either one enables its json feature — that feature is on for everyone using serde_json in that build, not just for the crate that asked for it. Cargo builds each dependency exactly once per build, with the union of every feature anyone requested.

This means a feature must only ever add capability (extra functions, extra impls) — never change existing behavior in a way that could conflict with what another crate expects. If one crate needed json off and another needed it on in the same build, there is no way to satisfy both — Cargo has no concept of “on for me, off for you.”

Common mistakes

  • Using #[cfg(feature = "x")] without declaring x in [features]. It doesn’t error, but modern Cargo warns unexpected cfg condition value — and worse, the code is silently, permanently excluded because the feature can never be turned on. Always declare every feature you check.
  • Forgetting optional = true on the dependency. If serde_json isn’t optional, it gets compiled in for everyone, feature or not — the [features] entry becomes decorative and doesn’t shrink anyone’s build.
  • Forgetting the dep: prefix. json = ["serde_json"] (no dep:) also implicitly creates a public feature literally named serde_json that others can enable directly — usually not what you want. dep:serde_json enables the dependency without exposing a redundant feature name.
  • Designing a feature that changes behavior instead of adding it. Because features unify across the whole build, a feature that flips existing behavior (rather than adding a new function or impl) can silently change how a different crate in the same build behaves, purely because something else in the graph turned it on.
  • Never testing --no-default-features or feature combinations. Code behind a feature that’s always on in your own testing can silently rot — it compiles for you, but breaks the moment someone builds without your defaults.

More examples

Per-OS config file location

A settings file needs a different path on Windows than on Linux or macOS, and #[cfg(target_os = ...)] bakes in the right one before the binary is even built.

fn main() {
    #[cfg(target_os = "windows")]
    let config_path = "C:\\Users\\me\\AppData\\Roaming\\myapp\\config.toml";

    #[cfg(not(target_os = "windows"))]
    let config_path = "/home/me/.config/myapp/config.toml";

    println!("loading settings from {config_path}");
}

Test-only fixture data for a shopping cart

#[cfg(test)] lets a module carry sample data for its own tests without that data ever shipping inside the real binary.

fn cart_total(prices: &[f64]) -> f64 {
    prices.iter().sum()
}

#[cfg(test)]
fn sample_cart() -> Vec<f64> {
    vec![9.99, 4.50, 12.25] // only compiled in when running `cargo test`
}

fn main() {
    let prices = [9.99, 4.50, 12.25];
    println!("cart total: ${:.2}", cart_total(&prices));
}

A CLI’s colorized output behind a feature flag

A command-line tool’s colored output is nice-to-have, not core — gating it behind a color feature means everyone who doesn’t ask for it never even compiles that code path.

[features]
color = []
#[cfg(feature = "color")]
fn highlight(text: &str) -> String {
    format!("\x1b[32m{text}\x1b[0m") // green
}

#[cfg(not(feature = "color"))]
fn highlight(text: &str) -> String {
    text.to_string()
}

fn main() {
    println!("{}", highlight("build succeeded"));
}

Opt-in load-test randomness for a benchmarking CLI

A benchmarking CLI might simulate jittery network delay with the rand crate, but most runs want deterministic timing — an optional fuzz feature keeps that dependency out of the default build.

[dependencies]
rand = { version = "0.8", optional = true }

[features]
fuzz = ["dep:rand"]
#[cfg(feature = "fuzz")]
fn random_delay_ms() -> u64 {
    use rand::Rng;
    rand::thread_rng().gen_range(50..500)
}

#[cfg(not(feature = "fuzz"))]
fn random_delay_ms() -> u64 {
    200 // fixed, predictable delay when fuzzing is off
}

fn main() {
    println!("simulated request delay: {}ms", random_delay_ms());
}

Your turn

This Cargo.toml and lib.rs are supposed to make serde_json an opt-in dependency behind a json feature — someone who doesn’t need JSON shouldn’t have to compile it. But serde_json still gets compiled into every build, feature or not, and the feature does nothing:

[package]
name = "mytool"
version = "0.1.0"
edition = "2021"

[dependencies]
serde_json = "1"
#![allow(unused)]
fn main() {
#[cfg(feature = "json")]
pub fn to_json(value: &str) -> String {
    serde_json::to_string(value).unwrap()
}
}
Show solution

Two things are missing: the dependency was never marked optional, and the json feature that should control it was never declared, so to_json can never actually be turned on.

[package]
name = "mytool"
version = "0.1.0"
edition = "2021"

[dependencies]
serde_json = { version = "1", optional = true }

[features]
json = ["dep:serde_json"]
#![allow(unused)]
fn main() {
#[cfg(feature = "json")]
pub fn to_json(value: &str) -> String {
    serde_json::to_string(value).unwrap()
}
}

Now serde_json is compiled only when json is enabled (cargo build --features json), and to_json becomes reachable at exactly the same time — the feature and the dependency it needs turn on together instead of being two disconnected pieces.

Quick check

Remember this

  • #[cfg(...)] removes non-matching code at compile time — it’s not a runtime if, the code simply isn’t there.
  • #[cfg(target_os = "linux")], #[cfg(test)], and #[cfg(debug_assertions)] are the conditions you’ll reach for most.
  • Declare a feature in [features] in Cargo.toml; check it in code with #[cfg(feature = "name")].
  • optional = true on a dependency plus feature = ["dep:name"] is the standard way to make a heavy dependency opt-in.
  • Features are additive across the whole dependency graph — if anything enables one, it’s on for everyone using that dependency, so a feature must only add capability, never change existing behavior in a conflicting way.

Go deeper

Next:

Build scripts (build.rs)

Advanced · Runtime & ecosystem

What & why

Some things need to happen before your crate can even be compiled — generating Rust code from a schema, compiling a bundled C library, or stamping in the current git commit hash. build.rs is Cargo’s answer: a small, separate Rust program that Cargo compiles and runs automatically, ahead of the real build, purely to prepare things the real build needs. Think of it as a pre-flight checklist that runs itself — you don’t invoke it, Cargo notices it’s there and just does it.

The idea, slowly

Cargo runs it for you — no wiring required

Drop a file named build.rs next to your crate’s Cargo.toml (same folder, not inside src/), and the next cargo build compiles and runs it before compiling anything else in the crate. There’s no flag to pass and nothing to register — the filename and location are the entire configuration:

mytool/
├── Cargo.toml
├── build.rs        <- Cargo finds this automatically
└── src/
    └── main.rs
// build.rs
fn main() {
    println!("cargo::warning=this build script ran!");
}

That’s a complete, valid build script. It’s a normal Rust binary with its own fn main — but the point of what it prints only means something when Cargo itself runs it and reads its output, which is why you won’t see the same effect just pasting this into a generic Rust runner.

Talking to Cargo: specially-formatted println! lines

A build script can’t directly poke Cargo’s internals — instead it prints lines to stdout in a format Cargo watches for, each one a small instruction. The most important ones:

  • println!("cargo::rerun-if-changed=PATH"); — only re-run this build script if PATH changes. Without at least one of these, Cargo re-runs the script on every build, which is slow and unnecessary.
  • println!("cargo::rustc-env=KEY=VALUE"); — sets an environment variable that the crate’s own code can read at compile time with env!("KEY").
  • println!("cargo::rustc-link-lib=foo"); / cargo::rustc-link-search=PATH — tell the linker about a native library to link against and where to find it.
  • println!("cargo::rustc-cfg=my_flag"); — defines a custom cfg the main crate can check with #[cfg(my_flag)].
  • println!("cargo::warning=message"); — prints a warning visible during the build, without failing it.

Cargo only parses stdout for these — anything printed with eprintln! (stderr) is just shown as ordinary diagnostic text, never treated as an instruction.

Reading the value back: env!

cargo::rustc-env and reading it with env! are a matched pair — the build script sets the variable, and your crate’s normal code reads it as if it were baked in at compile time:

// build.rs
fn main() {
    println!("cargo::rustc-env=BUILD_TIME=2026-08-20");
}
// src/main.rs
fn main() {
    println!("built at {}", env!("BUILD_TIME"));
}

env! (unlike std::env::var) resolves at compile time — the value gets baked directly into the binary, and if the variable was never set, the crate fails to compile with a clear error rather than panicking later at runtime.

Common real uses

Code generation from a schema. A build script can read a .proto or GraphQL schema file, generate matching Rust structs into OUT_DIR (a directory Cargo gives every build script to write into), and the main crate pulls the generated file in with include!:

// build.rs
fn main() {
    println!("cargo::rerun-if-changed=schema.proto");
    // ... run a codegen library, writing output into OUT_DIR ...
}

Compiling and linking a bundled C library, typically with the cc crate:

// build.rs
fn main() {
    cc::Build::new()
        .file("src/vendor/foo.c")
        .compile("foo");
}

Embedding build metadata, like the current git commit hash, so the compiled binary can report exactly what it was built from:

// build.rs
use std::process::Command;

fn main() {
    let output = Command::new("git")
        .args(["rev-parse", "--short", "HEAD"])
        .output()
        .expect("failed to run git");
    let git_hash = String::from_utf8(output.stdout).unwrap();

    println!("cargo::rustc-env=GIT_HASH={}", git_hash.trim());
    println!("cargo::rerun-if-changed=.git/HEAD");
}
// src/main.rs
fn main() {
    println!("version {}", env!("GIT_HASH"));
}

Common mistakes

  • Forgetting cargo::rerun-if-changed. Without it, Cargo’s default re-run heuristics may not notice that a file your build script depends on changed, and it keeps using stale generated output.
  • Slow or network-dependent build scripts. Every cargo build pays this cost — a build script that hits the network makes builds slower, flakier, and non-reproducible offline. Keep them fast and self-contained.
  • Printing to eprintln! and expecting Cargo to notice. Only stdout lines are parsed as instructions; stderr output is just shown as text (visible with -vv or on failure), never acted on.
  • Assuming the build script runs on the target platform. It always runs on the host machine building the crate, even when cross-compiling for something else entirely — reading cfg!(target_os = ...) inside build.rs reports the host, not the target. Use the CARGO_CFG_TARGET_OS environment variable instead if the target matters.
  • Panicking with no message when required input is missing. A build script failure aborts the entire build, so a bare .unwrap() on a missing file leaves whoever hits it with a cryptic backtrace instead of a clear reason.

More examples

Generating a lookup table into OUT_DIR

A game precomputes a table of sine values at build time instead of shipping a hand-typed array, writing the generated Rust source into OUT_DIR for the crate to pull in.

// build.rs
use std::env;
use std::fs;
use std::path::Path;

fn main() {
    let out_dir = env::var("OUT_DIR").unwrap();
    let dest = Path::new(&out_dir).join("sine_table.rs");

    let mut code = String::from("pub const SINE_TABLE: [f64; 4] = [");
    for i in 0..4 {
        let angle = i as f64 * std::f64::consts::PI / 2.0;
        code.push_str(&format!("{:?}, ", angle.sin()));
    }
    code.push_str("];\n");

    fs::write(&dest, code).unwrap();
    println!("cargo::rerun-if-changed=build.rs");
}
// src/main.rs
include!(concat!(env!("OUT_DIR"), "/sine_table.rs"));

fn main() {
    println!("{:?}", SINE_TABLE);
}

Failing fast when a required config file is missing

A server crate would rather refuse to compile than start up later with a confusing runtime error, so its build script checks for config.toml up front and panics with a message that says exactly what to do.

// build.rs
use std::path::Path;

fn main() {
    if !Path::new("config.toml").exists() {
        panic!("config.toml is missing — copy config.toml.example and fill it in first");
    }
    println!("cargo::rerun-if-changed=config.toml");
}

Linking a bundled SQLite library

When the native library is already compiled and just needs linking, cargo::rustc-link-search and cargo::rustc-link-lib tell the linker where to look and what to link against, no cc crate required.

// build.rs
fn main() {
    println!("cargo::rustc-link-search=native=vendor/sqlite");
    println!("cargo::rustc-link-lib=static=sqlite3");
    println!("cargo::rerun-if-changed=vendor/sqlite");
}

Gating platform-specific code behind a build-time check

A build script can inspect the compilation target and emit a custom cfg flag, letting the main crate pick an OS-specific code path with an ordinary #[cfg(...)].

// build.rs
fn main() {
    if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux") {
        println!("cargo::rustc-cfg=has_epoll");
    }
}
// src/main.rs
fn main() {
    #[cfg(has_epoll)]
    println!("using epoll for event polling");

    #[cfg(not(has_epoll))]
    println!("falling back to a portable poller");
}

Your turn

This crate’s build.rs sets a version string, and main.rs tries to read it back with env! — but the crate fails to compile with error: environment variable APP_VERSION not defined at compile time:

// build.rs
fn main() {
    println!("cargo::rustc-env=BUILD_VERSION=1.2.3");
}
// src/main.rs
fn main() {
    println!("running version {}", env!("APP_VERSION"));
}
Show solution

The build script sets BUILD_VERSION, but main.rs reads APP_VERSION — a plain name mismatch. env! looks up the exact key given to it at compile time; it has no idea BUILD_VERSION was “meant” to be the version.

// build.rs
fn main() {
    println!("cargo::rustc-env=APP_VERSION=1.2.3");
}
// src/main.rs
fn main() {
    println!("running version {}", env!("APP_VERSION"));
}

Either rename the key in build.rs to match what main.rs reads, or vice versa — the two sides just have to agree on the exact name. This kind of drift is easy to introduce during a rename, since nothing connects the two KEY strings except you keeping them in sync by hand.

Quick check

Remember this

  • A build.rs at the crate root is compiled and run by Cargo automatically, before the rest of the crate builds — no extra configuration needed.
  • Communicate with Cargo via specially-formatted println! lines on stdout: cargo::rerun-if-changed=..., cargo::rustc-env=..., cargo::rustc-link-lib=..., and more.
  • cargo::rustc-env=KEY=VALUE pairs with env!("KEY") in your normal code to bake a compile-time value into the binary.
  • Common real uses: generating Rust code from a schema, compiling/linking a bundled C library, and embedding build metadata like a git commit hash.
  • The build script always runs on the host machine, not the target — don’t assume cfg! inside it reflects a cross-compilation target.

Go deeper

Next:

Docs and rustfmt

Beginner · Runtime & ecosystem

What & why

Rust ships with two tools that make your code pleasant to read and share: rustfmt formats your code to one standard style so you never argue about spacing again, and cargo doc turns special comments into a browsable website of documentation. Both are one command, both come free with Rust, and both make your future self (and teammates) much happier.

The idea, slowly

rustfmt: stop formatting by hand

Every programmer has spent time nudging spaces and line breaks to make code “look right.” rustfmt ends that entirely. It reads your file and rewrites it in the official Rust style — consistent indentation, spacing, and line wrapping — automatically. You run one command:

cargo fmt

That’s it. Your whole project is reformatted in place. The huge win isn’t just tidiness; it’s that everyone’s code looks identical, so diffs in version control show real changes, not someone’s personal spacing preferences. You stop debating style because a tool already decided.

Take this messy but valid code:

#![allow(unused)]
fn main() {
fn add(a:i32,b:i32)->i32{a+b}
}

After cargo fmt it becomes:

#![allow(unused)]
fn main() {
fn add(a: i32, b: i32) -> i32 {
    a + b
}
}

Same program, standard shape. You didn’t touch a single space by hand.

Doc comments: /// is special

Rust has two kinds of comments:

  • // — a normal comment. Notes to yourself. Tooling ignores it.
  • /// — a doc comment (three slashes). This one is documentation for the thing right below it, and cargo doc collects them into a real webpage.
/// Adds two numbers together and returns the result.
///
/// Use this when you need a sum.
fn add(a: i32, b: i32) -> i32 {
    a + b
}

fn main() {
    println!("{}", add(2, 3));
}

This runs fine on the Playground (doc comments are just comments to the compiler). The magic happens when you later run cargo doc — the /// text above add becomes its official description in the generated docs. Write /// the moment you write a public function, and your documentation is done before you forget how the function works.

Markdown inside doc comments

Doc comments understand Markdown, so you can add headings, lists, and code examples. A common convention is an # Examples section showing how to call the function:

#![allow(unused)]
fn main() {
/// Multiplies two numbers.
///
/// # Examples
///
/// ```
/// let result = multiply(2, 3);
/// assert_eq!(result, 6);
/// ```
fn multiply(a: i32, b: i32) -> i32 {
    a * b
}
}

There’s a bonus here that feels like magic: those code blocks inside doc comments are doctests. When you run cargo test, Rust actually runs the example and checks the assert_eq!. So your documentation can never silently go out of date — if the example stops working, your tests fail.

//! documents the file itself

One more slash-based sibling: //! (slash-slash-bang) documents the module or file it’s inside, rather than the item below it. You put it at the very top of a file to describe the whole module. In real generated code — like the SeaORM entity files in an Axum project — you’ll see lines like //! SeaORM Entity at the top: that’s the file describing itself.

Generating and viewing the docs

One command builds an HTML site for your whole project and opens it in your browser:

cargo doc --open

It documents your crate and your dependencies, all cross-linked, styled exactly like the official docs.rs pages you’ve been reading. Your /// comments become the descriptions.

Common mistakes

  • Using // when you meant ///. A two-slash comment is invisible to cargo doc, so your carefully written explanation never shows up in the generated docs. Three slashes for documentation.
  • Fighting the formatter by hand. Manually aligning code that cargo fmt will just rewrite wastes time and creates noisy diffs. Let the tool own formatting; run it before committing.
  • Doc examples that don’t compile. Because doctests actually run under cargo test, a broken example fails your test suite. That’s a feature — fix the example — but it surprises people who thought docs were “just comments.”
  • Writing docs for yourself, not the reader. “Calls internal_helper then returns” tells a user nothing. Describe what it does and when to use it, from the caller’s point of view.
  • Documenting the obvious and skipping the tricky. A doc comment that restates the function name adds no value; spend the words on the surprising behavior and the edge cases.

More examples

A doc comment that doubles as a test

Real crates lean on # Examples constantly, because the example is the test — if you ever change the function and break the promise in the docs, cargo test tells you immediately.

/// Converts a temperature from Celsius to Fahrenheit.
///
/// # Examples
///
/// ```
/// let f = celsius_to_fahrenheit(0.0);
/// assert_eq!(f, 32.0);
/// ```
fn celsius_to_fahrenheit(c: f64) -> f64 {
    c * 9.0 / 5.0 + 32.0
}

fn main() {
    println!("{}", celsius_to_fahrenheit(100.0));
}

Hiding setup lines in a doctest

Sometimes an example needs a few lines of scaffolding that would be noise for a reader. Prefix a line with # inside the code block and cargo doc hides it from the rendered page — but cargo test still compiles and runs it:

#![allow(unused)]
fn main() {
/// A shopping cart total, in cents.
///
/// # Examples
///
/// ```
/// # struct Cart { cents: u32 }
/// # impl Cart { fn total(&self) -> u32 { self.cents } }
/// let cart = Cart { cents: 1999 };
/// assert_eq!(cart.total(), 1999);
/// ```
struct Cart {
    cents: u32,
}
}

The reader sees a clean two-line example; the doctest quietly checks the whole thing still works.

Customizing rustfmt’s line width

rustfmt follows sensible defaults out of the box, but a team can tune them project-wide by dropping a rustfmt.toml next to Cargo.toml. Every cargo fmt run in that project then follows these settings instead of the defaults:

# rustfmt.toml
max_width = 100
tab_spaces = 4
use_small_heuristics = "Max"

No flags to remember, no per-developer settings — everyone who runs cargo fmt in this project gets the same 100-column style automatically.

Browsing your own crate’s docs like a visitor

Once you’ve written a few /// comments, generate the site and actually read it the way a user of your crate would:

cargo doc --open

This builds the HTML docs for your crate and every dependency, then opens your default browser straight to your crate’s page. It’s the fastest way to catch a confusing doc comment — read it as a stranger would, not as the person who just wrote the code.

A module-level doc comment for context

//! at the top of a file introduces the whole module before a reader sees any individual item — handy for explaining scope, like “this file only validates data, it never touches the database”:

//! Small helpers for validating usernames before they hit the database.
//!
//! Keep this module free of database or network code — just plain checks.

/// Returns `true` if a username is between 3 and 20 characters and has no spaces.
fn is_valid_username(name: &str) -> bool {
    let len = name.chars().count();
    (3..=20).contains(&len) && !name.contains(' ')
}

fn main() {
    println!("{}", is_valid_username("shamirul"));
    println!("{}", is_valid_username("a b"));
}

Your turn

This function is documented with the wrong comment style, so cargo doc will ignore the explanation entirely. Fix it so the description becomes real documentation. (The program still runs either way — press Run to confirm — but only one version documents greet.)

// Returns a friendly greeting for the given name.
fn greet(name: &str) -> String {
    format!("Hello, {name}!")
}

fn main() {
    println!("{}", greet("Shamirul"));
}
Show solution

Change the two-slash comment into a three-slash doc comment:

/// Returns a friendly greeting for the given name.
fn greet(name: &str) -> String {
    format!("Hello, {name}!")
}

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

// is a normal comment the documentation tool skips. /// is a doc comment attached to the item directly below it, so cargo doc picks it up and shows it as greet’s description. The program runs the same either way — the difference only appears when you generate the docs.

Quick check

Remember this

  • cargo fmt reformats your whole project to the standard style — never format by hand.
  • // is a normal comment; /// is a doc comment that cargo doc turns into documentation.
  • //! documents the enclosing file/module (put it at the top of a file).
  • Code examples inside doc comments are doctestscargo test runs them, so docs stay correct.
  • cargo doc --open builds and opens a browsable HTML site for your crate and its dependencies.
  • Write docs for the caller: what it does and when to use it, not how it works internally.

Go deeper

Next:

Workspaces and crates

Intermediate · Runtime & ecosystem

What & why

A crate is one unit of Rust code that compiles together — basically one library or one program. A workspace is a folder that holds several crates and builds them as a team, sharing one lock file and one target/ build folder. You reach for a workspace when a single project grows big enough that splitting it into pieces (an API crate, a core-logic crate, a shared-types crate) keeps things sane.

The idea, slowly

First, what exactly is a crate?

The word “crate” gets used loosely, so let’s pin it down. A crate is the smallest amount of code the Rust compiler considers at once. There are two kinds:

  • A binary crate produces a program you can run — it has a main function. Your CLI or web server is a binary crate.
  • A library crate produces code meant to be used by other crates — it has no main. serde and axum are library crates.

A package is what Cargo manages: a folder with a Cargo.toml. A package contains one or more crates. When you run cargo new myapp, you get a package with one binary crate. Most of the time “crate” and “package” feel like the same thing, and that’s fine while learning.

Why split a project up at all?

Imagine one giant main.rs with 10,000 lines: the web routes, the database code, the business logic, the shared data types, all tangled together. Problems pile up:

  • Change one line and Cargo recompiles everything — slow.
  • There’s no enforced boundary, so the database code can secretly reach into the web code and create a mess.
  • You can’t reuse the business logic in a second program (say, a CLI) without dragging the whole web server along.

Splitting into separate crates fixes all three: each crate compiles on its own (faster rebuilds), the boundaries between them are enforced by the compiler, and you can reuse a crate anywhere.

What a workspace looks like

A workspace is a top-level Cargo.toml that lists its member crates. It has no [package] section of its own — just [workspace]:

# Cargo.toml at the project root
[workspace]
resolver = "2"
members = [
    "crates/api",     # the web server (binary crate)
    "crates/core",    # business logic (library crate)
]

The folder layout that goes with it:

myproject/
├── Cargo.toml          <- the workspace file above (no [package])
├── Cargo.lock          <- ONE shared lock file for everyone
├── target/             <- ONE shared build output folder
└── crates/
    ├── api/
    │   ├── Cargo.toml   <- has its own [package]
    │   └── src/main.rs
    └── core/
        ├── Cargo.toml   <- has its own [package]
        └── src/lib.rs

The two big shared things — Cargo.lock and target/ — are what make it a workspace instead of just two unrelated folders. Shared lock file means every crate uses the same version of each dependency. Shared target/ means a dependency is compiled once and reused, not rebuilt per crate.

Making one crate use another

Inside crates/api/Cargo.toml, you depend on the core crate by pointing at its path:

[package]
name = "api"
version = "0.1.0"
edition = "2021"

[dependencies]
core = { path = "../core" }

Now api’s code can call anything core marked as pub:

// in crates/api/src/main.rs
use core::greet;

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

The boundary is real: api can only touch what core chose to make public. That’s the compiler enforcing your architecture for you.

Running things in a workspace

From the root, Cargo commands understand the whole workspace:

cargo build                  # build every crate
cargo run -p api             # run a specific crate by its package name
cargo test                   # test every crate

The -p (for “package”) flag picks one member when you don’t want all of them.

Sharing dependency versions in one place

A newer, very handy feature: declare a dependency’s version once at the workspace root, and let every crate inherit it. That stops the classic bug where crate A uses serde 1.0.150 and crate B uses 1.0.200:

# root Cargo.toml
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }

# a member's Cargo.toml
[dependencies]
serde = { workspace = true }

Common mistakes

  • Splitting into crates too early. If the boundary between “api” and “core” isn’t real yet, you’ll spend more time shuffling code between crates than building. Start with one crate; split only when a seam clearly exists.
  • Putting a [package] section in the workspace root. The root Cargo.toml for a pure workspace has [workspace] and no [package]. Mixing them up confuses Cargo about what to build.
  • Forgetting path = "../core" for local crates. Cargo looks up dependencies on crates.io by default; a local crate needs an explicit path or Cargo can’t find it.
  • Version drift between crates. Without [workspace.dependencies], different members can pin different versions of the same crate, causing duplicate compiles and subtle type-mismatch errors.
  • Expecting private items to cross the boundary. One crate can only use another’s pub items. Forgetting pub gives a “not found” error even though the item is right there.

More examples

An app crate built on a shared core crate

The classic split: core holds business logic with no framework attached, and api (a thin binary crate) just wires that logic up to HTTP. core doesn’t know api exists, which means you could later add a cli crate that reuses the exact same logic for free.

myproject/
├── Cargo.toml            # [workspace], members = ["crates/api", "crates/core"]
└── crates/
    ├── core/
    │   ├── Cargo.toml     # [package] name = "core"
    │   └── src/lib.rs     # pub fn calculate_discount(...) -> f64 { ... }
    └── api/
        ├── Cargo.toml     # [dependencies] core = { path = "../core" }
        └── src/main.rs    # use core::calculate_discount;

core stays a plain library with zero web dependencies, so it’s easy to unit test in isolation — no server needs to be running just to check a discount calculation.

Sharing one dependency version across every member

Without a shared table, it’s easy for api/Cargo.toml to end up on serde = "1.0.150" while core/Cargo.toml drifts to serde = "1.0.200" — two copies get compiled and types stop matching across the crate boundary. Declaring the version once at the root and inheriting it everywhere closes that gap:

# root Cargo.toml
[workspace]
resolver = "2"
members = ["crates/api", "crates/core"]

[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
# crates/api/Cargo.toml
[dependencies]
serde = { workspace = true }
tokio = { workspace = true }
core = { path = "../core" }

Bump the version in one place at the root, and every member that opted in with workspace = true moves together.

Building or running just one member

A workspace with five crates doesn’t mean you always want to compile all five. Point Cargo at one package by name with -p:

cargo run -p api          # only builds+runs the api crate (and what it depends on)
cargo build -p core       # only builds the core crate
cargo test -p core        # only runs core's tests, not api's

This is the everyday command during development — you’re usually iterating on one crate at a time, and -p skips rebuilding crates you didn’t touch.

Why one shared Cargo.lock actually matters

Picture api and core as separate projects instead of workspace members, each with its own Cargo.lock. Nothing stops api from locking serde at 1.0.150 while core locks it at 1.0.203 — two different versions of the same crate, compiled separately, with no guarantee that a Serialize impl from one version even matches a type from the other.

Inside a workspace, there is exactly one Cargo.lock at the root, shared by every member. Cargo resolves each dependency to a single version that satisfies all crates at once. api and core are always building against the identical serde, so passing a core type through an api handler just works — the compiler never sees “two different serdes” because there’s only ever one.

Your turn

This is a spot-the-bug in a Cargo.toml, since a workspace isn’t a runnable program. This root file is meant to define a workspace with two members, but Cargo rejects it. What’s wrong, and why?

[package]
name = "myproject"
version = "0.1.0"

[workspace]
members = ["crates/api", "crates/core"]
Show solution

The root file mixes a [package] section into what should be a pure workspace root. A workspace root Cargo.toml describes the group, not a package to build, so it should have only [workspace]:

[workspace]
resolver = "2"
members = ["crates/api", "crates/core"]

The actual packages live in crates/api/Cargo.toml and crates/core/Cargo.toml, each with its own [package] section. Keeping [package] out of the root is what tells Cargo “this folder coordinates crates; it isn’t a crate itself.” (You can have a package at the root too — a “root package” workspace — but for a clean multi-crate layout, keep the root workspace-only.)

Quick check

Remember this

  • A crate is one compile unit: a binary crate has main, a library crate is meant to be used by others.
  • A workspace groups multiple crates, sharing one Cargo.lock and one target/ folder.
  • The workspace root Cargo.toml has [workspace] with a members list and (usually) no [package].
  • Depend on a local crate with path = "../other"; you can only use its pub items.
  • Split into crates only when a real boundary exists — enforced boundaries and faster rebuilds are the payoff.
  • Use [workspace.dependencies] to pin shared dependency versions in one place.

Go deeper

Next:

Clippy and formatting

Beginner · Runtime & ecosystem

What & why

Clippy is Rust’s linter — a tool that reads your code and points out things that are technically correct but could be clearer, faster, or more idiomatic. Paired with rustfmt (the formatter), it’s like having a patient senior developer look over your shoulder for free. Run both regularly and your Rust gets better without you memorizing every rule.

The idea, slowly

Compiler errors vs clippy warnings

You already know the compiler: it stops your program when something is wrong. Clippy is different — it looks at code that already compiles fine and suggests how to make it better. The compiler cares “does this work?”; clippy cares “is this the nice way to write it?”

For example, this compiles without complaint:

#![allow(unused)]
fn main() {
let x = 5;
if x == true {   // wait — x is a number, not a bool; this wouldn't compile,
}                // but clippy catches subtler style issues that DO compile
}

Clippy’s specialty is the huge middle ground of code that works but isn’t idiomatic. It knows hundreds of common patterns and the cleaner Rust way to write each one.

Running clippy

One command checks your whole project:

cargo clippy

You’ll get warnings like this (imagine you wrote if done == true):

warning: equality checks against true are unnecessary
 --> src/main.rs:3:8
  |
3 |     if done == true {
  |        ^^^^^^^^^^^^ help: try: `done`

Notice it doesn’t just complain — it tells you the fix: try: done. Clippy almost always suggests the better version, so you learn idiomatic Rust one warning at a time. Some fixes can even be applied automatically with cargo clippy --fix.

A few classic clippy catches

Once you’ve run clippy a few times, you start recognizing its favorite lessons:

  • if x == true → just write if x.
  • x.len() == 0 → write x.is_empty() (clearer and sometimes faster).
  • return x; on the last line of a function → drop the return and the ;, just write x.
  • Looping with an index to read a vector → use for item in &vec instead.
  • Calling .clone() when a borrow would do → clippy nudges you toward the cheaper option.

None of these are errors. Your program runs fine either way. Clippy is teaching you to write Rust the way experienced Rust programmers write it.

rustfmt: the formatter half

Where clippy fixes logic and style choices, rustfmt fixes layout — spacing, indentation, line breaks. One command reformats everything to the official standard:

cargo fmt

The two tools do different jobs and don’t overlap: cargo fmt makes your code look standard, cargo clippy makes your code read better. Run both.

Make them part of your loop

The whole point is to run these constantly, not once a year. A healthy habit while working on any project:

cargo fmt        # tidy the layout
cargo clippy     # catch style and correctness smells
cargo test       # make sure it still works

Many projects also run cargo clippy in CI (the automated checks on every pull request) and even turn warnings into hard failures with cargo clippy -- -D warnings, so no un-idiomatic code sneaks in. As a beginner you don’t need that yet — just get in the habit of running clippy and reading what it says.

Common mistakes

  • Never running clippy at all. You miss hundreds of small lessons and your code stays un-idiomatic longer than it needs to. It’s one command — run it.
  • Ignoring warnings because “it compiles.” Compiling only means it works; clippy is about writing it well. The warnings are the free mentoring.
  • Blindly applying every suggestion without understanding it. Clippy is usually right, but occasionally a lint doesn’t fit your situation. Read the suggestion, understand why, then decide. You can silence a specific lint with #[allow(clippy::lint_name)] when you mean it.
  • Confusing clippy with rustfmt. They’re different tools: cargo fmt handles spacing and layout, cargo clippy handles style and logic smells. Running one doesn’t do the other’s job.
  • Fighting the formatter by hand. Manually re-aligning code that cargo fmt will just rewrite wastes effort and muddies your diffs. Let the tool own layout.

More examples

Clippy catching a needless .clone()

Cloning “just to be safe” is a common habit, but if the function only ever reads the data, a borrow does the same job for free — no copy of the whole Vec<String> needed.

fn total_len(names: &[String]) -> usize {
    names.iter().map(|n| n.len()).sum()
}

fn main() {
    let names = vec![String::from("Ferris"), String::from("Corro")];

    // clippy: "redundant clone" -- names is only read here, no need to clone it
    let total = total_len(&names.clone());
    println!("{total}");

    // after the fix: just borrow, no clone at all
    let total2 = total_len(&names);
    println!("{total2}");
}

Both print the same number. The fixed version skips allocating a whole second copy of every string just to read their lengths.

Simplifying if x == true

This is clippy’s most famous catch, and a good one to internalize early — comparing a bool to true is never clearer than just using the bool itself:

fn main() {
    let logged_in = true;

    // clippy: "equality checks against true are unnecessary"
    if logged_in == true {
        println!("welcome back (old way)");
    }

    // after the fix
    if logged_in {
        println!("welcome back (idiomatic)");
    }
}

Silencing one lint on purpose, with a reason

Sometimes clippy’s default advice genuinely doesn’t fit — here, an index-based loop is intentional because the code needs the index (for a rank number) and the value, not just the value. Rather than fight the lint or ignore the warning silently, allow it locally and say why:

fn main() {
    let scores = vec![10, 20, 30];

    // We need both the index (for a rank) and the value, so the usual
    // "iterate directly" advice doesn't fit -- silence the lint and say why.
    #[allow(clippy::needless_range_loop)]
    for i in 0..scores.len() {
        println!("rank {}: {}", i + 1, scores[i]);
    }
}

Putting the #[allow(...)] right above the code it applies to (rather than at the top of the file) keeps the exception narrow and documents the reasoning exactly where a future reader will wonder about it.

Enforcing clippy in CI

A warning nobody reads doesn’t stop bad code from merging. Many teams turn clippy’s warnings into hard failures for automated checks, so a pull request can’t land until the lints are clean:

cargo clippy -- -D warnings

-D warnings means “deny warnings” — clippy now exits with a non-zero status if it finds anything to flag, which is exactly what a CI pipeline needs to fail the build instead of quietly logging a warning nobody scrolls up to see.

Your turn

Clippy can’t run on the Playground, but this program shows two things it would flag. The code works — press Run — but it isn’t idiomatic. Rewrite it the way clippy would suggest.

fn main() {
    let names = vec!["a", "b", "c"];

    if names.len() == 0 {
        println!("empty");
    }

    let mut i = 0;
    while i < names.len() {
        println!("{}", names[i]);
        i = i + 1;
    }
}
Show solution
fn main() {
    let names = vec!["a", "b", "c"];

    if names.is_empty() {          // clearer than `.len() == 0`
        println!("empty");
    }

    for name in &names {           // iterate directly, no manual index
        println!("{name}");
    }
}

Why clippy prefers this:

  • names.is_empty() instead of names.len() == 0 — it says exactly what you mean and can be faster (no need to count everything just to compare with zero).
  • for name in &names instead of a while loop with an index — it’s shorter, can’t go out of bounds, and is the standard Rust way to walk a collection. The manual i = i + 1 counter is exactly the kind of thing clippy nudges you away from.

Both versions run identically. Clippy’s job is helping you write the second one by habit.

Quick check

Remember this

  • The compiler stops code that’s wrong; clippy improves code that already works.
  • cargo clippy prints warnings with suggested fixes — it teaches idiomatic Rust one lint at a time.
  • cargo fmt (formatter) handles layout/spacing; cargo clippy (linter) handles style and logic smells — different jobs.
  • Classic catches: x == truex, .len() == 0.is_empty(), manual index loops → for x in &v.
  • Run cargo fmt, cargo clippy, and cargo test as a regular loop, not once in a while.
  • Read each suggestion to understand why; silence a lint deliberately with #[allow(...)] when it truly doesn’t fit.

Go deeper

  • Clippy — The official linter guide.

Next:

Web services

Intermediate · Runtime & ecosystem

What & why

A web service is a program that sits and waits for HTTP requests — a browser or an app asks for /products, and your program sends back an answer (usually JSON). Rust is excellent for this: services are fast, use little memory, and rarely crash. This lesson explains the shared shape of every Rust web server before you pick a framework like Axum.

The idea, slowly

A web server is a waiter

Picture a restaurant. A request is a customer’s order; a response is the plate you bring back. The server (your program) runs forever, taking orders and returning plates. Each kind of order maps to a route — a URL path plus a method (GET, POST, …) — and each route has a handler, the function that prepares that particular dish.

  • GET /health → handler returns “ok”
  • GET /products → handler returns a JSON list of products
  • POST /products → handler reads JSON from the request and creates a product

That request-in, response-out shape is identical across Axum, Actix, and Warp. Learn it once.

Handlers are just functions that return data

The core idea that makes Rust web servers click: a handler is an ordinary async function, and its return value becomes the response. You don’t manually write bytes to a socket; you return a value, and the framework turns it into HTTP.

#![allow(unused)]
fn main() {
// The simplest possible handler: takes nothing, returns some text.
async fn health() -> &'static str {
    "ok"
}
}

That async keyword matters. A web server juggles thousands of connections at once, and it can’t afford to freeze while one slow database call finishes. async lets a handler pause (while waiting for the database) so the server can serve other requests in the meantime, then resume. You don’t manage that yourself — an async runtime called Tokio does.

Wiring routes with Axum

Axum (the framework in a real backend like yours) lets you build a router: a table mapping paths to handlers. Then you hand it to a server that listens on a port:

use axum::{routing::get, Router};

async fn health() -> &'static str {
    "ok"
}

#[tokio::main]
async fn main() {
    // Build the routing table: GET /health -> health handler.
    let app = Router::new().route("/health", get(health));

    // Listen for connections on port 4000.
    let listener = tokio::net::TcpListener::bind("0.0.0.0:4000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

This needs Tokio and Axum, so it will not run on the Playground. In a real project:

cargo add axum
cargo add tokio --features full
cargo run

Then visit http://localhost:4000/health and you’ll see ok. Read the flow: Router::new() starts an empty routing table, .route("/health", get(health)) adds one entry, and axum::serve runs the loop forever. #[tokio::main] is what turns your async fn main into a real program by starting the Tokio runtime.

Returning JSON (where serde comes in)

Text is the “hello world” of web servers; real APIs return JSON. This is where the serde lesson pays off: you return a struct wrapped in Json, and Axum + serde serialize it for you.

#![allow(unused)]
fn main() {
use axum::Json;
use serde::Serialize;

#[derive(Serialize)]
struct Product {
    id: u64,
    name: String,
}

// Returning Json<T> makes the response JSON automatically.
async fn get_product() -> Json<Product> {
    Json(Product { id: 1, name: String::from("Keyboard") })
}
}

The handler just returns data; the framework handles serialization and sets the right headers. This is why “async and serialization usually show up together” — almost every real handler returns a serde-serializable type.

Shared state: the database connection

Handlers usually need something shared, like a database connection pool. You don’t make a new connection per request; you create it once at startup and share it. Axum passes it in through State:

#![allow(unused)]
fn main() {
use axum::extract::State;

#[derive(Clone)]
struct AppState {
    // e.g. a database connection pool
    db: String,
}

async fn list_products(State(state): State<AppState>) -> String {
    format!("querying with {}", state.db)
}
}

At startup you build the state once and attach it with .with_state(state). Every handler that asks for State<AppState> gets a cheap clone of the shared handle — exactly how a real Axum + SeaORM backend shares its database connection across all routes.

The one rule about blocking

Because async lets handlers pause and share threads, there’s a trap: if a handler does a slow blocking operation (a heavy computation, or std::thread::sleep, or blocking file I/O), it freezes the thread and other requests can’t run. The rule: inside async handlers, use async versions of things (async database calls, tokio::time::sleep), or move heavy blocking work off to a dedicated thread. Don’t quietly drop blocking code into an async handler.

Common mistakes

  • Forgetting #[tokio::main] (or a runtime). async fn main alone doesn’t run — async code needs a runtime to drive it. Without Tokio started, nothing happens.
  • Blocking inside an async handler. A slow synchronous call (big computation, thread::sleep, blocking I/O) freezes the thread and stalls other requests. Use async equivalents or offload the work.
  • Making a new database connection per request. That’s slow and exhausts the database. Build a connection pool once at startup and share it via State.
  • Returning a type that isn’t a valid response. A handler must return something Axum can turn into a response (&str, String, Json<T>, a status code, …). Returning a bare struct that doesn’t implement the response trait won’t compile.
  • Forgetting #[derive(Serialize)] on JSON responses. Json(value) needs value to be serializable; without the derive you get a trait-bound error.

More examples

These snippets need Axum, serde, and Tokio, so none of them run on the Playground — read them the way you’d read a recipe, then try the shapes in a real cargo new project.

Returning a list, not just one item

A dashboard rarely wants a single record — it wants the whole list. The pattern is identical to a single Json<T> response, just with Vec<T> as the type:

#![allow(unused)]
fn main() {
use axum::{routing::get, Json, Router};
use serde::Serialize;

#[derive(Serialize)]
struct Task {
    id: u64,
    title: String,
    done: bool,
}

async fn list_tasks() -> Json<Vec<Task>> {
    Json(vec![
        Task { id: 1, title: "Write lesson".into(), done: true },
        Task { id: 2, title: "Review PR".into(), done: false },
    ])
}

fn router() -> Router {
    Router::new().route("/tasks", get(list_tasks))
}
}

Reading a piece of the URL with Path

A profile page needs to know which user was requested. Axum captures a segment of the URL and hands it to your handler as a typed value — no manual string splitting:

#![allow(unused)]
fn main() {
use axum::{extract::Path, routing::get, Router};

async fn get_user(Path(id): Path<u64>) -> String {
    format!("looking up user #{id}")
}

fn router() -> Router {
    // matches GET /users/42, /users/7, ...
    Router::new().route("/users/{id}", get(get_user))
}
}

Axum parses id straight into a u64 for you; if someone requests /users/not-a-number, the request is rejected before your handler even runs.

Reading ?q=...&limit=... with Query

Search boxes and filters live in the query string, not the path. Query<T> deserializes it into a struct the same way Json<T> deserializes a request body:

#![allow(unused)]
fn main() {
use axum::{extract::Query, routing::get, Router};
use serde::Deserialize;

#[derive(Deserialize)]
struct SearchParams {
    q: String,
    limit: Option<u32>,
}

async fn search(Query(params): Query<SearchParams>) -> String {
    let limit = params.limit.unwrap_or(10);
    format!("searching for '{}' (limit {})", params.q, limit)
}

fn router() -> Router {
    // GET /search?q=rust&limit=5
    Router::new().route("/search", get(search))
}
}

limit is Option<u32> because it’s fine for a caller to omit it — unwrap_or(10) supplies a sensible default when they do.

One shared pool, many handlers

The earlier AppState example showed one handler reading shared state. The real payoff shows up once several handlers share the exact same pool — nobody opens a fresh database connection per route:

#![allow(unused)]
fn main() {
use axum::{extract::State, routing::get, Router};
use std::sync::Arc;

// In a real app this would be a sqlx::PgPool or similar connection pool.
struct Db;

#[derive(Clone)]
struct AppState {
    db: Arc<Db>,
}

async fn list_products(State(state): State<AppState>) -> String {
    let _pool = &state.db; // the same pool every handler shares
    "querying products".to_string()
}

async fn list_orders(State(state): State<AppState>) -> String {
    let _pool = &state.db; // no new connection created here either
    "querying orders".to_string()
}

fn router(state: AppState) -> Router {
    Router::new()
        .route("/products", get(list_products))
        .route("/orders", get(list_orders))
        .with_state(state)
}
}

Cloning AppState is cheap — it’s just cloning the Arc, a reference count bump, not the database connection itself.

Combining Path and State in one handler

Real handlers usually need more than one extractor at once — here, the URL supplies which user, and shared state supplies how to look them up:

#![allow(unused)]
fn main() {
use axum::{extract::{Path, State}, routing::get, Json, Router};
use serde::Serialize;

#[derive(Clone)]
struct AppState {
    db: std::sync::Arc<String>, // pretend connection pool
}

#[derive(Serialize)]
struct User {
    id: u64,
    name: String,
}

async fn get_user(State(_state): State<AppState>, Path(id): Path<u64>) -> Json<User> {
    Json(User { id, name: format!("user-{id}") })
}

fn router(state: AppState) -> Router {
    Router::new().route("/users/{id}", get(get_user)).with_state(state)
}
}

Axum extractors compose freely like this — list as many as the handler needs, in any order, and each one pulls out exactly the piece of the request it’s responsible for.

Your turn

This is a spot-the-bug, since a web server can’t run on the Playground. This handler is supposed to return a product as JSON, but it won’t compile. There are two problems. What are they?

#![allow(unused)]
fn main() {
use axum::Json;

struct Product {
    id: u64,
    name: String,
}

fn get_product() -> Json<Product> {
    Json(Product { id: 1, name: String::from("Keyboard") })
}
}
Show solution
#![allow(unused)]
fn main() {
use axum::Json;
use serde::Serialize;

#[derive(Serialize)]                 // 1. Json<T> requires T: Serialize
struct Product {
    id: u64,
    name: String,
}

async fn get_product() -> Json<Product> {   // 2. handlers must be async
    Json(Product { id: 1, name: String::from("Keyboard") })
}
}

The two fixes:

  1. #[derive(Serialize)] — wrapping a value in Json<T> only works if T can be serialized. Without the derive, serde has no idea how to turn Product into JSON, and the trait bound fails.
  2. async fn — Axum handlers must be async so they can pause on slow work without blocking the server. A plain fn won’t satisfy Axum’s handler requirement.

Quick check

Remember this

  • Every web server is a loop: request in, response out; a route (path + method) maps to a handler.
  • A handler is an async fn whose return value becomes the response — you return data, the framework builds the HTTP.
  • async + the Tokio runtime let one server juggle thousands of requests; start it with #[tokio::main].
  • Return Json<T> (with T: Serialize) to send JSON — this is why serde and web services go together.
  • Build shared resources (like a DB pool) once and pass them to handlers via State.
  • Never do slow blocking work inside an async handler — it stalls other requests.

Go deeper

  • Axum docs — Popular Rust web framework docs.

Next:

Rust and WebAssembly

Advanced · Runtime & ecosystem

What & why

WebAssembly (WASM) is a compact, sandboxed instruction format that browsers (and other hosts) can run at near-native speed. Rust has no garbage collector and a tiny runtime, which makes it an unusually good fit for compiling into that sandbox — you get real performance in the browser without shipping a language runtime alongside it. wasm-bindgen is the piece that makes this actually usable: it generates the glue code so Rust functions can be called from JavaScript, and JavaScript values can flow into Rust, without you hand-writing any of the marshalling.

The idea, slowly

A sealed appliance, not a normal program

A .wasm module is more like a sealed appliance than a regular executable — it runs inside a sandbox with no access to the outside world except what its host (the browser) explicitly hands it. It can’t open a file, spawn a thread the way std::thread expects to, or open a raw socket, because the browser simply doesn’t expose those capabilities to WASM code. Everything Rust code compiled to WASM does, it does by calling into JavaScript functions the host provides — which is exactly what wasm-bindgen sets up.

The wasm32-unknown-unknown target

Rust can compile to many targets beyond your own machine. The one for the browser (and generic WASM hosts) is wasm32-unknown-unknown — 32-bit WASM, no particular vendor, no particular OS (hence “unknown-unknown”, since there’s no operating system underneath it). You add it once per machine:

rustup target add wasm32-unknown-unknown

and then build for it explicitly:

cargo build --target wasm32-unknown-unknown --release

That alone produces a raw .wasm binary — a real artifact, but not yet something convenient to call from JavaScript. For that, you need wasm-bindgen.

#[wasm_bindgen]: the interop glue, generated for you

Mark a function pub and attach #[wasm_bindgen], and the macro generates everything needed to call it from JavaScript — converting a Rust String to and from a JS string, matching up numeric types, and emitting a small .d.ts/JS wrapper so the function looks like a normal JS function on the other side:

#![allow(unused)]
fn main() {
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn greet(name: &str) -> String {
    format!("Hello, {name}!")
}
}

After building, this becomes callable from JavaScript as plainly as:

import { greet } from "./pkg/mytool.js";
console.log(greet("Ferris")); // "Hello, Ferris!"

You never write the conversion code between &str and a JS string yourself — #[wasm_bindgen] generated it at compile time.

wasm-pack build: packaging the result

Compiling to wasm32-unknown-unknown gives you a .wasm file; wasm-bindgen’s macro prepares your code to be called correctly — but something still has to run the wasm-bindgen post-processing step and assemble a package JavaScript can actually import. That’s wasm-pack:

cargo install wasm-pack
wasm-pack build --target web

This compiles your crate to WASM, runs the wasm-bindgen CLI over the result, and writes a pkg/ folder containing the .wasm binary, a generated JS module, type definitions, and a package.json — ready to import directly in a web page (--target web) or publish to npm.

Not every crate compiles to WASM

Because the browser sandbox has no filesystem, no OS threads, and no raw sockets, any crate that assumes those exist can fail to compile for wasm32-unknown-unknown, or compile but panic the moment it’s actually called. Concretely:

  • std::fs calls have nothing to read or write — there’s no filesystem underneath.
  • std::thread::spawn doesn’t work the normal way — the browser’s main thread model doesn’t match native OS threads (real WASM threading exists, but needs special support, not plain std::thread).
  • TCP/UDP sockets aren’t available at all — browsers only expose networking through fetch and WebSocket, which JavaScript has to bridge in for you.

When a dependency needs one of these, look for a WASM-specific alternative, or feature-gate the native-only code path out with #[cfg(not(target_arch = "wasm32"))].

Panics: from an opaque crash to a real message

By default, a panic in Rust compiled to WASM surfaces in the browser console as something like RuntimeError: unreachable executed — no message, no file, no line number, because the panic message never makes it across to JavaScript on its own. During development, install a panic hook so panics get forwarded to console.error with the real message:

use wasm_bindgen::prelude::*;

#[wasm_bindgen(start)]
pub fn main() {
    console_error_panic_hook::set_once();
}

#[wasm_bindgen(start)] marks this function to run automatically the moment the module is loaded, so the hook is installed before anything else has a chance to panic. From then on, a panic prints its actual message and location to the browser console instead of a bare, unhelpful runtime error.

Common mistakes

  • Forgetting to add the target before building. cargo build --target wasm32-unknown-unknown fails immediately if the target was never installed — run rustup target add wasm32-unknown-unknown once per machine first.
  • Using std::fs, real OS threads, or sockets in code that needs to run in the browser. It may compile, but fails or panics the moment it actually runs in the sandbox, since none of those capabilities exist there.
  • Skipping the panic hook. Without console_error_panic_hook::set_once(), every panic during development shows up as an unreadable, message-free JS exception — costing real debugging time for something a one-line hook fixes.
  • Passing large or complex data across the JS/Rust boundary casually. Each call across the boundary has real conversion cost; for big payloads, prefer typed arrays or a crate like serde-wasm-bindgen over many small calls.
  • Using the wrong --target with wasm-pack build. --target web produces an ES module you initialize yourself; the default --target bundler assumes a bundler like webpack is doing that step. Mixing them up breaks the import style you expected in your JS code.

More examples

None of these compile on the Playground — they need wasm-bindgen (and sometimes web-sys) and a real wasm32-unknown-unknown build. Read them as patterns to try in a wasm-pack project.

Transforming a string for a browser UI

A common reason to reach for WASM at all: doing text processing fast, in a function shared between a Rust backend and a Rust-compiled-to-WASM frontend, instead of writing the logic twice.

#![allow(unused)]
fn main() {
use wasm_bindgen::prelude::*;

/// Turns "Hello World" into a URL-friendly "hello-world".
#[wasm_bindgen]
pub fn slugify(input: &str) -> String {
    input
        .to_lowercase()
        .split_whitespace()
        .collect::<Vec<_>>()
        .join("-")
}
}

Called from JavaScript as slugify("Hello World"), returning "hello-world" — the same slug logic your server already trusts, now running client-side with no round trip to the API.

Exposing a struct with methods, not just a function

#[wasm_bindgen] isn’t limited to free functions — put it on an impl block too, and JavaScript gets something that behaves like a real class:

#![allow(unused)]
fn main() {
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub struct Counter {
    value: i32,
}

#[wasm_bindgen]
impl Counter {
    #[wasm_bindgen(constructor)]
    pub fn new() -> Counter {
        Counter { value: 0 }
    }

    pub fn increment(&mut self) {
        self.value += 1;
    }

    pub fn value(&self) -> i32 {
        self.value
    }
}
}

From JavaScript: const c = new Counter(); c.increment(); c.value(); // 1new, method calls, and reading fields all just work, generated from ordinary Rust methods.

Calling console.log from Rust

Before pulling in the whole web-sys crate, the smallest way to reach the browser console is to bind directly to it — this is the same pattern the wasm-bindgen guide’s own console-log example uses:

#![allow(unused)]
fn main() {
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(js_namespace = console)]
    fn log(s: &str);
}

#[wasm_bindgen]
pub fn process_order(order_id: u32) {
    log(&format!("processing order #{order_id}"));
}
}

The extern "C" block declares a function that already exists on the JS side (console.log); calling log(...) from Rust is really calling straight into the browser’s console.

Loading a --target web package in an actual page

wasm-pack build --target web produces an ES module you load directly with a <script type="module"> tag — no bundler required for a quick demo:

wasm-pack build --target web
<script type="module">
  import init, { greet } from "./pkg/mytool.js";

  async function run() {
    await init(); // fetches and instantiates the .wasm file
    console.log(greet("Ferris"));
  }

  run();
</script>

The init() call matters: with --target web, the module doesn’t load the .wasm binary until you await init() yourself, so nothing bound with #[wasm_bindgen] is callable before that line runs.

Sharing one crate between native and WASM builds

A crate that’s meant to run both as a native CLI and compiled to WASM needs two implementations of anything that touches the filesystem — #[cfg] picks the right one per target, at compile time:

#![allow(unused)]
fn main() {
#[cfg(not(target_arch = "wasm32"))]
fn log_to_file(message: &str) {
    // native builds can write straight to disk
    std::fs::write("app.log", message).ok();
}

#[cfg(target_arch = "wasm32")]
fn log_to_file(message: &str) {
    // the browser has no filesystem -- forward to the console instead
    web_sys::console::log_1(&message.into());
}
}

Every other caller in the crate just calls log_to_file(...) normally — the #[cfg] attributes make sure only one of the two versions is even compiled, depending on the target.

Your turn

This crate is meant to expose a greet function to JavaScript via wasm-bindgen, but wasm-pack build --target web fails with Error: crate-type must be cdylib to compile to wasm32-unknown-unknown:

[package]
name = "greeter"
version = "0.1.0"
edition = "2021"

[dependencies]
wasm-bindgen = "0.2"
#![allow(unused)]
fn main() {
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn greet(name: &str) -> String {
    format!("Hello, {name}!")
}
}
Show solution

By default a Rust library compiles to an rlib — a format meant for other Rust crates to link against, not something wasm-bindgen’s tooling can turn into a .wasm module plus JS glue. It needs a cdylib (a C-compatible dynamic library) artifact to post-process instead. Add a [lib] section declaring it:

[package]
name = "greeter"
version = "0.1.0"
edition = "2021"

[dependencies]
wasm-bindgen = "0.2"

[lib]
crate-type = ["cdylib", "rlib"]
#![allow(unused)]
fn main() {
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn greet(name: &str) -> String {
    format!("Hello, {name}!")
}
}

Keeping "rlib" alongside "cdylib" means the crate can still be used as a normal Rust dependency (in tests, or from another native crate) as well as compiled to WASM — wasm-pack build --target web now finds the cdylib artifact it needs and produces a working pkg/ directory.

Quick check

Remember this

  • #[wasm_bindgen] on a function or struct exposes it to JavaScript, generating the marshalling code automatically.
  • Build with the wasm32-unknown-unknown target (rustup target add wasm32-unknown-unknown), then wasm-pack build runs the compile and the bindgen step, producing a ready-to-import pkg/ directory.
  • Not every crate compiles to WASM — anything depending on threads, the filesystem, or raw sockets has nothing to run on in the browser sandbox.
  • Cargo.toml needs crate-type = ["cdylib", "rlib"] in [lib]wasm-bindgen’s tooling needs the cdylib artifact to post-process.
  • Set a panic hook (console_error_panic_hook::set_once()) during development so panics show a real message instead of an opaque JS exception.

Go deeper

Review & flashcards

This is your anti-forgetting page. It pulls the key questions from every lesson into one shuffled deck. Cards you mark “Shaky” come back first next time, so your weak spots get the most practice. Everything is saved in your browser — no account, no login.

Try to answer each card in your head before you flip it. That effort of recalling is what actually builds the memory.