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:
-
Read the idea, slowly. Each topic is explained in plain English first — the why before the how. No wall of jargon.
-
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."); } -
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.
-
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:
- Get set up — install the tools, print “Hello, world”, learn to read the compiler’s error messages (that last skill is worth gold).
- Language basics — variables, types, functions,
if/match, loops. This is the ordinary stuff every language has. - Ownership and borrowing — the Rust-specific core. Go slow here.
- Everyday building blocks — structs, enums, and the standard library’s collections and strings.
- 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
unsafefor now.
Go deeper
- The Rust Book — Start with the official learning path.
- Rust by Example — Short runnable examples.
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 typecargofar more thanrustc. It builds, runs, tests, and pulls in other people’s code. (It has its own lesson next.)rustup— the installer and updater. It installsrustcandcargo, 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
rustupfrom 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 --versionandcargo --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:
- Go to rustup.rs and run the command it gives you for your OS, accepting the standard defaults.
- Close your terminal and open a brand-new one.
- 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.
rustupmanagesrustc(the compiler) andcargo(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 --versionandcargo --version; both should print version numbers. - Update anytime with
rustup update. Prefer rustup over distro/Homebrew packages.
Go deeper
- rustup — Official installer.
- Cargo Book - Getting Started — What Cargo expects from the toolchain.
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.rsis where your code lives. Cargo even fills it with a working “Hello, world” so the project runs immediately.Cargo.tomlis 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. (.tomlis 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 (--releasefor 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 runcargo runand get an error about no manifest /Cargo.tomlnot found, you’re probably one folder too high.cdinto the project first. - Forgetting to
cdaftercargo new.cargo new hello-rustmakes the folder but leaves you outside it. You mustcd hello-rustbeforecargo rundoes anything. - Reaching for
cargo cleanat the first weird error.cargo cleandeletes 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.lockfor 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; thencdinto it before running anything.cargo runbuilds and runs — it’s your most-used command.cargo checkis a fast way to catch errors without a full build; great while fixing them.- Dependencies (crates) are listed in
Cargo.toml; add them withcargo add <crate>from crates.io. Cargo.lockpins exact dependency versions for repeatable builds — commit it for applications.
Go deeper
- Cargo Book — The full reference.
- Cargo manifest reference — How
Cargo.tomlis structured.
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()—fnmeans “I’m defining a function.”mainis a special name: when you run a Rust program, it starts atmain. Always. Ifmainisn’t there, there’s nothing to run. Think ofmainas 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, andprintln!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 (;).
Print more than one line
Each println! is its own instruction, running in order:
fn main() {
println!("Learning Rust.");
println!("One line at a time.");
}
Print a value with {}
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 namedprintln— 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
- The Rust Book - Hello, world — The canonical first program.
- Rust by Example - Hello World — A second view of the same concept.
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.errormeans the build failed.E0382is 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 usednameafter its value had moved away. --> src/main.rs:4:20— the location. Filesrc/main.rs, line4, column20. 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, thenameyou tried to use too late.
- The notes — lines like “move occurs because
namehas typeString, which does not implement theCopytrait.” This is the compiler explaining why, in words. It’s telling youStringmoves 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 readinghelp:turns a 10-second fix into a 10-minute fight. - Treating warnings like errors (or ignoring them forever). A
warningstill 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
warningstill compiles and runs; onlyerrorstops the build. - Stuck on a code? Run
rustc --explain E0382(with your code) for a fuller explanation.
Go deeper
- Rustc error index — Search specific compiler errors.
- Rust Book - Common Programming Concepts — Where many first errors appear.
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 islet mut x = 1;. This is the single most common early error, and the compiler even suggests addingmut. - Thinking shadowing mutates the old value. It doesn’t.
let x = 5; let x = x + 1;makes a newx. If you expected to see the old value somewhere else, you’ll be confused — nothing changed the first one, it’s just hidden. - Adding
mutyou never use. If you writelet mut x = 5;but never changex, Rust warns: “variable does not need to be mutable.” Drop themut. It’s a hint that your intent and your code disagree. - Confusing
constandlet.constneeds an uppercase name and an explicit type, and can’t usemut. If you tryconst 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
letnames a value; by default that value is immutable (can’t be changed).- Add
mutwhen you genuinely need to reassign:let mut x = .... - Shadowing (
let xtwice) 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
- Rust Book - Variables and Mutability — Core syntax and examples.
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
imeans signed (can be negative). The letterumeans 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
i32to au8for you.let a: i32 = 1; let b: u8 = 2; a + bfails 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 achar;"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
Vecinstead when the size changes. - Overflowing a small integer. A
u8maxes 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,u32is unsigned,u8/u64are smaller/bigger. - Decimals are floats;
f64is the default. Booleans aretrue/false. Acharis 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
Vecinstead. - Rust infers types when it can, but you must annotate when it genuinely can’t decide (like
parse).
Go deeper
- Rust Book - Data Types — Scalars and compound types.
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
asto shrink a value and being surprised later.asnever panics and never warns — it truncates or wraps silently. If out-of-range input would be a bug, useTryFrom/TryIntoinstead. - Assuming
asclamps negative numbers to zero when casting to an unsigned type. It doesn’t — it reinterprets the bit pattern, so-1i32 as u8is255, not0. - Implementing
Fromfor a conversion that can actually fail.Frompromises the conversion always succeeds. If some inputs are invalid, implementTryFromand returnErrfor them. - Calling
.unwrap()on.parse()ortry_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
ascasts 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
ascasts truncate toward zero, and saturate to the target’s min/max instead of producing garbage for out-of-range floats. From/Intoare for conversions that always succeed — implementFrom, andIntocomes for free.TryFrom/TryIntoreturnResultfor conversions that can fail — reach for these when an out-of-range value is a real bug."text".parse::<T>()converts a string intoTviaFromStr, returningResult<T, T::Err>.
Go deeper
- Rust Book - Type Conversions —
ascasting rules. - std::convert docs — From, Into, TryFrom, TryInto.
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 aStringinstead of printing anything.write!/writeln!— write formatted text into anything that implementsstd::fmt::Write(like aString) orstd::io::Write(like a file), returning aResultyou’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
Debugoutput where a user will see it.{:?}is for developers debugging; implementDisplayfor anything a real user reads. - Forgetting
#[derive(Debug)].{:?}on a type without it is a compile error (“the traitDebugis not implemented”), not a blank line at runtime. - Dropping the
Resultfromwrite!.write!/writeln!can fail (writing to a file, for instance), so Rust warns on an unusedResult— 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 aString,write!/writeln!write into anyfmt::Write/io::Writetarget.{}usesDisplay(user-facing);{:?}/{:#?}useDebug(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
- std::fmt docs — Full formatting syntax reference.
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 add—fnmeans “I’m defining a function,” andaddis its name.(a: i32, b: i32)— these are the parameters: the inputs. Each one needs a name and a type.ais ani32,bis ani32. Rust never guesses parameter types — you must always write them.-> i32— the arrow says “this function hands back ani32when 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 computea + 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 + 3is an expression — it becomes5.
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(), notx. The compiler says “expectedi32, 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
-> i32but the last expression is text, you get “mismatched types.” The declared type and the actual returned value must agree. - Adding
;after anif-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
returnkeyword needed. - Adding a
;to that last line throws the value away and returns()(nothing) — a very common error. - Use
returnto leave a function early; leave off-> Typewhen a function returns nothing.
Go deeper
- Rust Book - Functions — Basic function syntax.
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 abool. Write the comparison:if count != 0 { }. Same forwhile. - Mismatched
ifbranch types. When anifproduces 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..4stops at 3, not 4. Forgetting the end is excluded leads to loops that run one time too few. Use1..=4when you want the last number included. breakoutside a loop.breakonly works insideloop,while, orfor. Using it elsewhere is an error.- Forgetting to change the
whilecondition. 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/whileconditions must be a realbool— Rust never treats numbers as true/false.ifis an expression: it can produce a value, and all branches must share one type.loopruns forever untilbreak(andbreak valuecan return a value);whileruns while a condition holds;forwalks a collection.- Ranges:
1..4excludes the end (1,2,3);1..=4includes it (1,2,3,4). - Prefer
for item in collectionover manual indexing — it can’t run off the end.
Go deeper
- Rust Book - Control Flow — Conditionals and loops.
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, feedscargo 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 xwastes 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 incargo 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 feedscargo 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
- Rust by Example - Documentation — How doc comments work.
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
mainfunction. Yourhello worldwas 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 itpubfails with “function is private.” Addpubto 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
modanduse.moddeclares a module (creates the drawer or points at its file).usejust makes an existing name shorter to type. They are not the same;usealone won’t create a module. - Wrong path with
::.parser.parse()(a dot) is not how you reach into a module — that’s method syntax. Useparser::parse()with the double colon for paths. pubon the function but not its enclosing module. If a module is private, marking an inner functionpubstill won’t let outside code reach it — the whole path must be reachable. Make the parent modulepubtoo 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;
pubexposes an item to code outside its module. - Reach into a module with
::(a path), e.g.parser::parse. Method calls use a dot. usebrings a name into scope so you can type its short form. The standard library is thestdcrate.
Go deeper
- Rust Book - Modules — Module basics.
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
puband assuming its fields come along for free. Each field needs its ownpub; apubstruct with nopubfields is unconstructible and unreadable from outside its module (unless you provide public methods). - Reaching for
pub“to be safe.” Everypubitem 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.
pubexposes 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 usere-exports an item under a new path, letting callers ignore your internal module layout.- A
pubstruct’s fields are still private unless each one is markedpubindividually.
Go deeper
- Rust Reference - Visibility and Privacy — Exact privacy rules.
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 needsnameandactivefails 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
muton individual fields. Mutability lives on the binding:let mut user = ...makes the whole struct changeable. Ifuserisn’tmut, no field can be assigned. - Confusing the blueprint with an instance.
struct User { ... }defines the type; it does not create a user. You still needlet user = User { ... }to get an actual value. - Wrong field name or type.
User { naem: ... }(typo) or putting a number where aStringgoes 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
structbundles 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 needslet muton the whole binding. - Field init shorthand:
nameinstead ofname: namewhen 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
- Rust Book - Structs — How to define and instantiate structs.
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 Hash — Eq 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/Hashso"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
Ordbased on just one field (or reversed), not every field in declaration order.
Common mistakes
- Deriving
Copyon a struct with aString/Vec/Boxfield. Fails with “the traitCopymay not be implemented for this type” — those fields own heap data and can’t be bitwise-duplicated safely. - Deriving
Eq/Ordon a struct containing anf64/f32field. Floats aren’tEq/Ordbecause ofNaN; you can derivePartialEq/PartialOrdon 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.Cloneis an explicit deep copy (.clone());Copymakes assignment implicitly duplicate — only for types where every field is itselfCopy, andCopyrequiresClone.PartialEq/Eqenable==;PartialOrd/Ordenable<and sorting — floats can’t deriveEq/Ordbecause ofNaN.Hash(together withEq) is what lets a type be aHashMap/HashSetkey.Defaultfills 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
- Rust Book - Derivable Traits (Appendix C) — Every standard derivable trait.
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 writeDirection::North, not justNorth. Without the prefix Rust doesn’t know which enum you mean (unless you brought it into scope withuse). - Treating a variant’s data as always accessible. You can’t just read the
Stringout ofMessage::Writedirectly — the value might be a different variant. You get the data by matching, which is exactly whymatchexists. - Reaching for
nullhabits. There’s nonullin Rust. “Might be missing” isOption, and you must handle theNonecase. 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
enumsays a value is exactly one of several variants — pick one withEnumName::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 fornull— missing values are handled, not ignored.- Enums pair with
matchto branch on the variant and unpack its data — that’s the next lesson.
Go deeper
- Rust Book - Enums — Variant-driven data.
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 ofmatch, 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.texton an enum value — it might be a different variant. You get the inner value by matching (orif let), which is exactly what these tools are for. - Using
matchwhenif letreads better (or vice versa). Amatchwith one real arm and a_ => ()is usually clearer asif let. Conversely, chaining manyif lets where a singlematchwould do makes code harder to follow. - Forgetting patterns bind new names. In
Some(n),nis a fresh name capturing the inner value — it does not compare against an existing variable calledn. This surprises people who expect it to mean “match only if equal ton.”
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
matchcompares a value to patterns top-to-bottom and runs the first that fits;=>separates pattern from action.- A
matchmust be exhaustive — cover every case or add_as a catch-all (kept last). - Patterns destructure:
Some(n)orMove { x, y }both branch and bind the inner data to new names. if letis the compact form for handling just one variant; use it when a fullmatchis overkill.- Patterns also work in
letbindings (let (a, b) = pair), with|for multiple patterns andifguards for extra conditions.
Go deeper
- Rust Book - Match — The core matching story.
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
&selfwhen you need to mutate. If a method changes a field, it must take&mut self. With&selfyou’ll get “cannot assign toself.x, which is behind a&reference.” Switch the receiver to&mut self. - Calling a
&mut selfmethod on a non-mutvalue.let c = Counter { ... }; c.increment();fails becausecisn’tmut. The variable must belet mut cto allow the mutable borrow. - Mixing up
.and::. Associated functions (noself) are called with:::Rectangle::new(...). Methods (withself) are called with a dot:rect.area(). Using the wrong one is a common early error. - Accidentally consuming with
self. A method that takesself(no&) moves the value; you can’t use the variable afterward. If you only meant to read, use&selfso the caller keeps ownership. - Forgetting
selfinside the method. Fields areself.width, not justwidth. Withoutself., Rust looks for a local variable namedwidthand 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. &selfborrows to read (most methods),&mut selfborrows to change,selfconsumes the value (rare).- Calling a
&mut selfmethod requires the variable to bemut. - Associated functions have no
self(often constructors likenew) and are called withType::function(). - Method calls use a dot (
value.method()); associated functions use::(Type::func()).String::fromis a familiar example.
Go deeper
- Rust Book - Methods — Receiver forms and impl blocks.
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
- Every value has exactly one owner (a variable that owns it).
- There can only be one owner at a time.
- 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.ais 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
Copytypes (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
- Rust Book - Understanding Ownership — The main ownership chapter.
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:
- The original variable must be declared
mut(it has to be changeable in the first place). - You pass it with
&mut. - 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&sbut the function still saysword: String, or vice versa, the types don’t match and you getmismatched 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 iscannot borrow ... as mutable, as it is behind a&reference. Fix: use&muteverywhere and make the original variablemut. - A
&mutwhile another borrow is alive.cannot borrow ... as mutable because it is also borrowed as immutablemeans you still have a reader hanging around. The fix is usually to stop using the earlier reference before you start the mutable one. - Forgetting
muton the variable itself. You can’t take a&mutof something that was never declaredmut. The error points you back to theletand says to addmut.
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;&mutlets you change the value (and needs the original to bemut). - 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
- Rust Book - References and Borrowing — Borrowing rules.
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 r — println! 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 + 1whenris&i32givescannot add {integer} to &{integer}. You forgot to dereference — use*r + 1. - Adding
*where the.already handles it. You rarely need(*r).len(); just writer.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&givescannot 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
- Rust by Example - Deref — Reference patterns and deref thinking.
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 withbyte 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 withbyte 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
Stringwhile 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). &stris a string slice — that’s why text literals and slices of aStringshare 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
- Rust Book - Slices — Borrowing part of a value.
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 calleda.” The apostrophe is just Rust’s way of writing lifetime names;'ais 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— “leftis a reference that lives at least as long as'a.”right: &'a str— “rightalso 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
'aextends 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 ownedStringinstead 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
'aonly when you seemissing 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
'aas a type.'ais not a type likei32; 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
'aonly when a function returns a reference and takes more than one. 'ais 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
- Rust Book - Validating References with Lifetimes — Where lifetime syntax is introduced.
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:
- Each elided (unwritten) reference in the input gets its own distinct lifetime.
- If there’s exactly one input lifetime, it’s assigned to every elided output lifetime.
- If one of the inputs is
&selfor&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
'staticas “this is a global.” It means “no borrow inside this value expires before the program theoretically could end” — an ownedStringori32satisfies it trivially, even as a completely ordinary, short-lived local variable. - Reaching for
'staticto make a lifetime error disappear. It usually just relocates the bug — now you can’t pass in a borrowed value at all where a'staticbound is required. Prefer fixing the ownership (clone the data, or store an owned type) over forcing a'staticbound. - 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, likelongest<'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 anExcerptis 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.
'staticmeans “can live for the whole program” — string literals are'static; aT: 'staticbound just meansTowns its data or only borrows'staticdata, not that the value itself is global.
Go deeper
- Rust Reference - Lifetime elision — The exact elision rules.
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 isSummarymust have asummarizemethod that returns aString.” 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 whereArticleactually keeps that promise.Tweetkeeps the same promise in a completely different way. Neither type knows the other exists.&selfborrows 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
implblock. Writingtrait Summary { ... }alone does nothing forArticle. You must writeimpl Summary for Article. The error isthe trait bound 'Article: Summary' is not satisfiedorno 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 withno method named 'summarize' found for type parameter 'T', because unboundedTcould 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
useit before its methods are callable on a value, even if the type already implements it. The error ismethod not found, fixed withuse 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)andfn f(x: &impl Trait)both mean “accept anything implementingTrait”; 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.
whereclauses 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
- Rust Book - Traits — Trait definitions and bounds.
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 Trait” at 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 return | dyn Trait | |
|---|---|---|---|
| Dispatch cost | none — direct call | none — direct call | one vtable lookup |
| Can mix concrete types? | no — one T per call site | no — one hidden type per function | yes, behind Box/&/Rc |
| Needs a pointer? | no | no | yes (unsized) |
| Binary size | bigger (copy per type) | normal | normal |
Common mistakes
- Writing
dyn Traitas a bare value or return type.fn make() -> dyn Shape { ... }fails withdoesn'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 errorthe trait '...' is not dyn compatiblemeans the trait isn’t object-safe — usually a generic method or a method returningSelf. - Reaching for
dyn Traitby 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 — savedynfor when you genuinely need runtime-chosen, mixed types. - Forgetting
impl Traitreturn 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 implementFn. UseBox<dyn Fn(...)>if the concrete type genuinely varies. - Confusing
Box<dyn Trait>withBox<T>.Box<T>still knows exactly whichTit holds and dispatches statically — only writingdynin 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 oneT. dyn Traituses 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 Traitis unsized and must live behind a pointer:Box<dyn Trait>(owned),&dyn Trait(borrowed), orRc<dyn Trait>(shared).- A trait is object-safe only if the compiler can build a fixed vtable for it — generic methods and methods returning
Selfbreak that. impl Traitin 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
- Rust Book - Trait Objects — When and how to use dyn Trait.
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 calledT.” You declare it here, like declaring a variable, before you use it.items: &[T]means “a slice of some typeT.” (A slice&[T]is a borrowed view of a list — you saw slices earlier.)-> &Tmeans “I return a reference to that same typeT.”
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 > bora + borprintln!("{a}")on a bareTfails, 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 thinkTis a real type it should already know, givingcannot 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
i32ever 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 Greetmixes 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
Tis a placeholder for a type, chosen when you call the code. - Declare it in angle brackets first:
fn name<T>(...)orstruct 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
- Rust Book - Generics — The basics of generic syntax.
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 T — Some(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, anddedupall mutate the vector, so it must belet mut. The error iscannot borrow as mutable. - Indexing out of bounds.
v[99]on a short vector panics at runtime withindex out of bounds. When the index isn’t already known to be valid, usev.get(99)and handle theOptioninstead. - Treating
pop()as if it returns the value directly. It returnsOption<T>, because there might be nothing left to pop.let x: i32 = v.pop();is a type error — you needv.pop().unwrap()(if you’re sure), or amatch/if let. - Expecting
dedup()to remove all duplicates. It only merges adjacent equal runs. Duplicates scattered through an unsorted vector survivededup()untouched — sort first. - Skipping
Vec::with_capacityin a hot loop. Repeatedly pushing into aVec::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
Vecwith{}. Use{:?}(debug format) for whole vectors; plain{}only works for single values with aDisplayimplementation.
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 needlet mutto be modified.v[i]panics on an out-of-range index;v.get(i)returnsOption<&T>instead — no crash.pop()returnsOption<T>, notT, because the vector might be empty.for x in &vborrows (vector stays usable);for x in vconsumes it.sort/sort_by/sort_by_keyreorder in place;retainkeeps only elements passing a test;dedupremoves 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
- std::vec::Vec docs — Full Vec API.
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;— unlikeVec,HashMapisn’t automatically in scope. Forget this line and you getcannot 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)returnsOption<&V>—Some(&value)if the key exists,Noneif 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 theusegivescannot find type 'HashMap' in this scope. - Using
get+insertwhereentrywould 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-lineif let/elsealmost every time. - A custom key type missing
Eq/Hash. The error is a compile-timethe trait bound '...: Eq' is not satisfied(orHash). 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
HashMappreserves 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 aHashMapand 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’sSome(old_value)on overwrite,Noneif 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)returnsOption<&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()returnsfalseif the value was already present.- Iteration order is unspecified and can change between runs — use
BTreeMap/BTreeSetwhen order matters.
Go deeper
- std::collections::HashMap docs — Entry API and full method list.
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 order — major 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
VecDequeeverywhere. If you only ever push and pop the back, plainVechas less overhead and is the better default — reach forVecDequespecifically when you need the front to be cheap too. - Expecting
BinaryHeapiteration to come out sorted. Only repeated.pop()guarantees largest-first order; iterating withfor 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 inReverse(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
BTreeMaporBinaryHeapa key/element type withoutOrd. The error isthe trait bound '...: Ord' is not satisfied. For a custom struct, add#[derive(PartialEq, Eq, PartialOrd, Ord)]— all four are needed, sinceOrditself depends on the other three. - Choosing
BTreeMappurely out of habit. If you never need sorted iteration or range queries,HashMap’s average O(1) lookups are faster thanBTreeMap’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/BTreeSetkeep keys sorted at all times (O(log n) operations) and support range queries — pick them overHashMap/HashSetwhen order matters.- Keys in a
BTreeMap/BTreeSetneedOrd(#[derive(PartialEq, Eq, PartialOrd, Ord)]on a custom struct). VecDequegives O(1) push/pop at both the front and back, unlikeVecwhich is O(n) at the front — the natural fit for queues and sliding windows.BinaryHeapis a max-heap by default:.pop()always returns the largest remaining element.- Wrap values in
std::cmp::Reverseto get min-heap behavior out of the sameBinaryHeap.
Go deeper
- std::collections module docs — A comparison table of every std collection.
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:
Stringis 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 number — word[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&strwon’t silently take aString. Pass&my_stringto borrow it down to a&str. Going the other way, turn a&strinto aStringwith.to_string()orString::from(...).- Trying to grow a
&str.push_str/pushneed an owned buffer, so they only exist onString. The fix is to start from aString, 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
Stringas a parameter when you only read it. This forces callers to give up ownership for no reason. Prefer&strfor 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
Stringowns growable text;&strborrows a view of existing text.- String literals like
"hi"are already&str. - Only
Stringcan grow (push_str,push) — a&strhas nothing of its own to grow. - For read-only text arguments, take
&str; it accepts bothString(via&) and literals. .len()is bytes, not characters; you can’t index text by number — use.chars().
Go deeper
- Rust Book - Storing UTF-8 Encoded Text with Strings — How Rust treats text.
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:
| Method | Yields | Effect 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 vwhen you still needvafterward. That form calls.into_iter(), which consumesv. If you need the collection again, loop over&vinstead. - Forgetting
muton a manually-driven iterator..next()takes&mut self;let iter = v.iter();followed byiter.next()won’t compile withoutlet mut iter = .... - Assuming an iterator “ran” just because you built it.
v.iter().map(...)alone does nothing — Rust warnsunused iterator that must be used. You need aforloop or a consumer. - Forgetting to dereference in
.iter_mut(). The loop variable is&mut T, notT— writingn = 5won’t compile, you need*n = 5to 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
Print a guest list without losing it
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
Iteratortrait boils down to one method:fn next(&mut self) -> Option<Self::Item>. - A
forloop is sugar for calling.into_iter()once, then loopingwhile let Some(x) = iter.next(). .iter()yields&T(borrow, collection stays usable),.iter_mut()yields&mut T(mutate in place),.into_iter()yieldsT(owned, consumes the collection).for x in &vborrows (like.iter());for x in &mut vmutably borrows (like.iter_mut());for x in vconsumes (like.into_iter()).- An iterator does nothing on its own — it needs
.next(), aforloop, or a consumer like.collect()to actually run.
Go deeper
- Rust Book - Processing a Series of Items with Iterators — Iterator fundamentals.
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 warnsunused Map that must be used. Add.collect(),.sum(), aforloop, 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/mapclosures receive references. Over.iter(),filter’s closure parameter is a reference to a reference (&&T) — you’ll often need*nor**nto 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
forloop 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<_>>().zipstops at the shorter of its two iterators;filter/mapclosures 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
forloop with a comment — clarity beats cleverness.
Go deeper
- std::iter::Iterator docs — the full list of adaptor and consumer methods.
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 onei32calledx.x * 2— the body. A one-expression closure doesn’t need{ }or areturn; 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 * 2isn’t a closure. The shape is|params| body. - Using a captured variable after
move. Once you writemove ||, captured owning values (like aString) are moved into the closure; touching the original afterward givesvalue moved. Only addmovewhen you actually need the closure to own its captures. - Expecting a closure to work in a place a plain
fnis 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 mentionsexpected 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;afterx + 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
fncan’t do that. - By default closures borrow what they capture; add
moveto make them own it (needed when the closure outlives the scope, e.g. threads). - Functions accept closures via the
Fn/FnMut/FnOncetrait bounds. - Keep closures small; promote big logic to a named function.
Go deeper
- Rust Book - Closures — How closures capture state.
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 firstNone/Err. Fine for a quick test; risky in anything a user runs. Prefermatch,if let, a combinator, or?(next lesson).- Passing
.and_then()a closure that returns a bare value instead ofOption/Result..and_then(|n| n + 1)doesn’t compile — the closure must returnSome(n + 1)(orOk/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
OptionwithResult. UseOptionwhen something is simply absent (no error to report); useResultwhen 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
Resultentirely. Rust warns if you drop aResulton the floor (unused Result that must be used). Handle it, propagate it, or explicitlylet _ = ...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)orNone— a value might be missing (Rust’s safe replacement for null).Result<T, E>=Ok(v)orErr(e)— an operation might fail with a reason.matchforces you to handle every case, so you can’t forget the failure path..map(f)transforms the success value (freturns a plain value);.and_then(f)chains another fallible step (freturnsOption/Result)..unwrap_or(default)/.unwrap_or_else(|| ...)get a plain value out with a fallback;.ok_or(err)and.ok()convert betweenOptionandResult;.filter()turnsSomeintoNonewhen a predicate fails.unwrap()/expect()grab the value but panic on failure; use them sparingly, mostly in tests and quick scripts.
Go deeper
- Rust Book - Error Handling — Option, Result, and
?.
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 returnResult/Option. The error isthe ? operator can only be used in a function that returns Result or Option. Change the function’s return type, or handle the error withmatchinstead. - Forgetting to wrap the success value in
Ok(...). In a-> Result<...>function, the happy path must returnOk(value), not a barevalue.?only rewrites the error path; the normal return is still your job. - Using
?across two error types with noFromimpl between them. If your function returnsResult<T, ParseIntError>but you?on something that fails withstd::io::Error, the compiler can’t find a conversion and refuses to build. Either widen the return type (e.g. toBox<dyn Error>), or write theFromimpl 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, notmain. If the closure’s return type isn’tResult/Optiontoo, 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 returnsResultorOption— there’s nowhere else for the early return to go.?callsFrom::fromon the error, so a function can return one error type while?-ing through several different underlying error types — as long as aFromconversion exists (or the target isBox<dyn Error>, which accepts anything).- The happy path still needs an explicit
Ok(value)(orSome(value)) —?only handles the early-return side. fn main() -> Result<(), E>lets you use?directly inmain.
Go deeper
- Rust Book - Propagating Errors — Where the ? operator is introduced.
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.Displayalone lets you print the error, but withoutError, your type won’t compose with code expectingBox<dyn Error>, and?can’t convert into other error types that rely on the blanketErrorconversions. - Skipping
Fromimpls and using.map_err(|e| ConfigError::Invalid(e))everywhere instead. It works, but it’s exactly the repetitionFrom+?exists to eliminate — implementFromonce 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 longermatchon 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
thiserrorcrate 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
matchmeaningful — resist collapsing everything into one catch-all variant. - Implement
Display(a human message) +std::error::Error(interoperability);ErrorrequiresDebug + Displayas supertraits. impl From<X> for MyErrorfor each source error type lets?auto-convert — implement it once, use?everywhere instead of.map_err(...)at every call site.- The
source()method onError(defaultNone) 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
thiserrorgenerates most of it for you.
Go deeper
- std::error::Error docs — The trait every error type should implement.
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 tomatchon which error happened to decide how to react —anyhow::Errorerases the concrete type, so a caller can’tmatchon it at all, only print or downcast it. - Binary / application code (the thing that’s actually run): use
anyhow. Nobody downstream needs tomatchon yourmain’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
anyhowin a library’s public API. It forces every downstream caller intoanyhowtoo, and they lose the ability tomatchon specific failures. Keepanyhowat the application boundary; expose athiserrorenum (or a plainResult<T, YourError>) from a library. - Calling
.context(...)withoutuse anyhow::Context;in scope.contextisn’t an inherent method onResult— 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.thiserrorgenerates oneFrom<X>impl per#[from]field; two fields of the sameXwould need two conflictingFrom<X>impls, which doesn’t compile. - Expecting to
matchon ananyhow::Error. It’s intentionally type-erased. If you need to distinguish error cases in application code, either keep using athiserrorenum there too, or.downcast_ref::<SpecificError>()on theanyhow::Error(rare, and usually a signthiserrorwas 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 generatesDisplayandstd::error::Error;#[from]on a field generates the matchingFromimpl, so?still auto-converts.anyhow::Result<T>=Result<T, anyhow::Error>— any error type implementingstd::error::Errorconverts into it via?, making it the fast default formainand application code..context("...")/.with_context(|| ...)(from theanyhow::Contexttrait — remember to import it) attach a human message to a failingResultwithout discarding the original error.anyhow::bail!("...")returns early with a formatted error;anyhow::ensure!(cond, "...")is a fallibleassert!— bails if the condition is false.- Rule of thumb:
thiserrorfor libraries (callers need tomatchon specific variants),anyhowfor binaries/applications (callers just want to log or exit).
Go deeper
- thiserror docs — Derive macro for error enums.
- anyhow docs — Catch-all error type for applications.
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 what5000ortruemean 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
Selffrom a setter. If a setter’s signature isfn 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 namedportfound for type()”. Always double-check every setter ends in-> Self { ...; self }. - Skipping validation in
.build(). If.build()just always returnsOk(...)(or isn’t fallible at all) for a struct with genuinely required fields, you’ve reinvented “trust me it’s fine” — exactly whatResultexists to avoid. UseOptionfields plus.ok_or(...)?for anything required. - Mutating through
&mut selfinstead of consumingself. Both styles exist in real code, but mixing them confuses callers: consuming-selfbuilders must be reassigned (b = b.port(80)) or chained directly;&mut selfbuilders 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 returnsSelf, so calls chain:Builder::new().name("x").port(8080).build(). .build()is where required-field validation happens, often returning aResult.- Prefer
Default+ struct-update syntax (..Default::default()) for simpler cases before reaching for a full builder. - A missing
-> Selfon one setter surfaces as a confusing error on the next method call, not on the setter itself.
Go deeper
- Rust API Guidelines - Builders — When and how to use a builder.
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
u64andStringin the codebase adds.0noise 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 gainf64::sqrt()or arithmetic operators automatically. You need.0to get at the inner value, or explicit trait impls (Deref,Add, …) to forward behavior. - Forgetting the newtype has no
Display/Debugby default. Printing a bareMeters(1.8)with{}fails to compile until you either#[derive(Debug)](for{:?}) or implementDisplayyourself (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 YourTypedirectly — 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 fromf64at compile time, at zero cost at runtime.- A newtype prevents accidentally passing a
UserIdwhere anOrderIdis expected, even though both are the same primitive underneath. - The orphan rule blocks
impl ForeignTrait for ForeignType— wrappingForeignTypein a newtype you own sidesteps it, because you now own the type in theimplslot. - Access the inner value with
.0, or implementDeref/Fromto make the wrapper more ergonomic to use.
Go deeper
- Rust Book - Using the Newtype Pattern — Newtype and the orphan rule.
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
Addgives you+=for free.AddAssign(which powers+=) is a separate trait fromAdd. Implementing one does not implement the other — if you want bothp1 + p2andp += p2to work, you implement bothAddandAddAssign. - Forgetting most
std::opstraits takeselfby value. If your type isn’tCopy,p1 + p2movesp1(andp2), so you can’t use them again afterward. Either deriveCopyfor small value-like types, or implement the operator for references (impl Add for &Point) so operands are borrowed instead of consumed. - Mismatched
Outputtype.type Output = Pointmust match whatadd’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 + bdesugars toAdd::add(a, b). impl Add for Point { type Output = Point; fn add(self, rhs: Point) -> Point { ... } }enablesp1 + p2.Index/IndexMutenablecontainer[key]syntax for your own collection-like types.+and+=are separate traits (AddandAddAssign) — 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
- std::ops module docs — Every overloadable operator trait.
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
- 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 testignores it. - 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.
- 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 runscargo test.” In a normalcargo buildorcargo 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 usedev-dependencies(test-only crates listed under[dev-dependencies]inCargo.toml) without those crates ever being linked into your real program.use super::*;is what makesaddvisible.mod testsis a child module, so it doesn’t automatically see its parent’s items.supermeans “the module I’m nested in,” anduse 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 adev-dependencycrate, 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 oneassert!/assert_eq!/assert_ne!. #[should_panic]withoutexpected = "...". 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 printsleftandright, notactualandexpected— Rust doesn’t know which side you intended as which. The convention isassert_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 withcargo test. assert!,assert_eq!, andassert_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
- Rust Book - Writing Automated Tests — Unit and integration testing.
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 yourpubsurface. If you need to check a private helper directly, that’s what a#[cfg(test)] mod testsunit test (previous lesson) is for. - Naming a shared helper file
tests/common.rs. Cargo runs it as its own (nearly empty) test crate. Usetests/common/mod.rsinstead so it’s only ever pulled in viamod common;. - Writing integration tests for a binary-only crate. With no
src/lib.rs, there’s no library fortests/*.rstouse— 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 downcargo 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
.rsfile directly undertests/is compiled as its own separate crate, linked against your library exactly like an external user’s project would be. - Integration tests only see
pubitems — 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.rsshares setup helpers viamod common;without being auto-discovered as its own test file —tests/common.rswould be.- Each file in
tests/triggers its own compile of your crate, so a large integration suite can noticeably slowcargo test.
Go deeper
- Rust Book - Test Organization — Unit vs integration test layout.
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 bycargo testby default. To show non-runnable or non-Rust code, use```textor mark the block```ignore. - Doc-testing something private. Doc-tests only see
pubitems, 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 forcriterion. - Expecting
cargo benchto work out of the box on stable. The built-in#[bench]/cargo benchpair is nightly-only; on stable you needcriterion(or a similar crate) withharness = 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:?}");
}
Benchmarking linear search against binary search
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 bycargo 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; thecriterioncrate is the standard for statistically sound benchmarks. criterioncompares each run to the last and reports whether a change is a real regression or just noise — andblack_boxkeeps the optimizer from deleting what you’re trying to measure.
Go deeper
- rustdoc book - Documentation tests — How doc-tests are collected and run.
- Criterion.rs docs — The standard benchmarking crate.
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 callfs::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::writeoverwrites. It replaces the entire file. If you meant to add to it, you neededOpenOptions::new().append(true). - Using
?in a function that returns().?needs to return an error somewhere. The function it lives in must returnResult(orOption), includingmainif 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
.rsfile 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::fsfunction returns aResultbecause file access can always fail. fs::read_to_string(path)reads a whole file;fs::write(path, data)writes (and overwrites) one.- The
?operator unwrapsOkor early-returnsErr— but only inside a function that returnsResult/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
- std::fs docs — Standard file APIs.
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::varreturnsErr. Code that doesenv::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
.envfile with real secrets. Once it’s in git history, the secret is compromised — rotate it, don’t just delete the file. Keep.envin.gitignore; commit a.env.exampleinstead. - Calling
dotenvy::dotenv()after you’ve already read the vars you need. It has to run before theenv::varcalls that depend on it, right at the top ofmain. - Using bare
.unwrap()on a required env var. It crashes withcalled \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::varcalls 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")returnsResult<String, VarError>— a missing variable is anErr, never a silent empty string.dotenvy::dotenv().ok()at the top ofmainloads a.envfile into the process environment for local dev convenience — call it before anyenv::varreads that depend on it.- Never commit real secrets in
.env— keep it in.gitignore, commit a.env.exampleinstead, 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 looseenv::varcalls. - Prefer
.expect("clear message")over bare.unwrap()on required config — future-you (or 2am on-call you) will thank you.
Go deeper
- std::env docs — Environment and process introspection.
- dotenvy docs — .env file loading.
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::ExitCodefrommain. This is a normal function return — everything on the stack gets cleaned up (Dropruns) 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 runDropfor 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 asVec<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 runDropfirst. It skips destructors for everything still on the stack — files may not flush, locks may not release. Prefer returningExitCodefrommainso 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 toprintln!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()orstatus.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::ExitCodefrommaininstead of callingstd::process::exitmid-function — returning still runsDropfor everything on the stack;exitskips 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
- std::process docs — Command, ExitCode, and process control.
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 needthread::spawn(move || ...), or the compiler complains the borrow might outlive the data. - Forgetting to
.join(). Ifmainends 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 aResult, not the value inside — you need.unwrap()or amatch/if letto get atT. .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 theErrexplicitly 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
rayoncrate) 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:
- The closure borrows
numbers, butthread::spawnneeds a'staticclosure — addmoveso the thread ownsnumbersoutright. handle.join()returns aResult<i32, _>, not ani32.Resultdoesn’t implementDisplay, soprintln!("{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;moveis needed because the closure might outlive the caller’s stack frame..join()blocks until the thread finishes and returns aResult<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 asErr. .join().unwrap()turns a worker’s panic into a panic on the thread that called it — match theErrexplicitly 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
- Rust Book - Using Threads — Spawning and joining threads.
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
Senderclone alive somewhere — including an unused original sitting inmain— 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
Senderalive. A forgotten clone (or the originaltx, if you only ever used clones) keeps the channel open forever, sofor msg in rxnever returns. - Blindly
.unwrap()-ing.send()in a long-running producer.sendreturnsErronce theReceiverhas been dropped — if the reader gave up early, unwrapping panics the sender. Handle theResultif that’s a real possibility. - Trying to clone the
Receiver. OnlySenderisClone. Withstd::sync::mpscthere’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 —sendreturns immediately, buffering the value. If you want backpressure (the sender blocks when the buffer is full), usempsc::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: cloneSenderfor more producer threads, but there’s only ever oneReceiver..send(value)movesvalueacross the channel — the receiving thread gets ownership, not a reference.for msg in rxblocks and yields messages until everySender(original plus clones) has been dropped, then the loop ends.- A stray
Senderclone left alive anywhere keeps the channel open forever — drop what you don’t need. .send()returns aResultthat becomesErronce theReceiveris gone — don’t blindly.unwrap()it in a long-running producer.
Go deeper
- std::sync::mpsc docs — Channel API reference.
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
MutexGuardlonger 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 ofArc<Mutex<T>>across threads.RcandRefCellaren’tSend/Sync; the compiler refuses to let them cross a thread boundary. - Locking the same
Mutextwice 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 aResult..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>isRc<T>’s thread-safe sibling:Arc::clonebumps an atomic refcount (cheap) — it doesn’t copy the data.mutex.lock()blocks until the lock is free, then hands back aMutexGuardthat derefs to&mut T.- The
MutexGuardunlocks 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
- Rust Book - Shared-State Concurrency — Arc, Mutex, and deadlock pitfalls.
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 fnruns it. It only builds aFuture. Nothing executes until something.awaits or polls it. - Using
.awaitoutside async code..awaitis only legal inside anasync fnorasyncblock. Writingvalue.awaitin a plainfn main()is a compile error:`await` is only allowed inside `async` functions and blocks(errorE0728). - Letting a future drop unused. A future you never
.awaitor spawn never runs, even if you called the function that created it. The compiler’sunused implementer of Futurewarning is your safety net — don’t ignore it. - Treating
.awaitas “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 fndoes not run its body — it immediately returns aFuture, a paused state machine sitting at step zero. .awaitdrives a future forward by polling it, and only works insideasynccode.- While a future is waiting on something,
.awaityields control back instead of blocking the OS thread — that’s the entire efficiency win over a blocking call. async/.awaitare just language syntax. Rust ships no executor — you need a runtime (like Tokio) to actually drive a future to completion.- A future you never
.awaitor spawn never runs, even though the compiler happily let you create it — watch for theunused implementer of Futurewarning.
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
moveon a spawned task.tokio::spawn(async { ... })that borrows a local fails withE0373(“may outlive the current function”). Addmoveso 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, synchronousstd::fscalls, or a tight CPU loop all freeze the worker thread they run on, stalling every other task scheduled there. Usetokio::time::sleep,tokio::fs, orspawn_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, orselect!when you actually needed every result. They’re not interchangeable:join!always waits for all;select!always cancels the rest. - Missing the
tokiodependency or#[tokio::main]entirely. Async code with no runtime either fails to compile (.awaitneeds 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; forgettingmoveon 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_blockingto 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$msgsubstituted 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_functy— 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$xthat 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]orprintln("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 (liken += 1or aprintln!), 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 + 1where 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$nameis 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 howvec!-style variadic macros work.
Go deeper
- Rust Book - Macros — Declarative macro syntax.
- The Little Book of Rust Macros — a deep dive once you want to go further than fragment specifiers and repetition.
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 typeserde::Serialize. - A
proc-macro = truecrate can only export macros. It can’t also export a normalpub fnorpub structfor 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 = truecrate 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 needserde = { version = "1", features = ["derive"] }. Without it, the derive macro simply doesn’t exist yet, and you get a “cannot find derive macro” error even thoughserdeis 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 likesqlx::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]needstokio = { version = "1", features = ["full"] }(or at leastrt-multi-threadandmacros) — 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 expandbefore 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 likemacro_rules!calls but run arbitrary compile-time logic. - A derive macro never modifies your struct/enum definition — it generates a separate
implblock next to it. - A proc macro must live in its own crate with
proc-macro = trueinCargo.toml— you can’t define and use one in the same crate. cargo expandprints 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
- Rust Reference - Procedural Macros — How proc macros work under the hood.
- cargo-expand — install it once, reach for it constantly.
- Serde: Using derive — the derive macro you’ll meet first in real projects.
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/Arconly when you hit the specific problem it solves. - Thinking
Rc::clonecopies the data. It doesn’t — it just adds an owner and bumps a counter. The underlying value is shared, not duplicated. - Using
Rcacross threads. It won’t compile (Rcisn’tSend). The compiler is protecting you from a data race on the counter. Switch toArc. - Expecting to mutate through
Rc/Arc. They hand out shared (immutable) access. To mutate shared data, pair them withRefCell(single-thread) orMutex(multi-thread). - Creating reference cycles with
Rc. If twoRcs point at each other, their counts never reach zero and the memory leaks. UseWeakreferences 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::clonebumps an owner count, it doesn’t copy.Arc<T>= the thread-safe version ofRc; use it whenever threads share ownership.Rc/Arcgive shared read access; combine withRefCell/Mutexto mutate shared data.
Go deeper
- Rust Book - Smart Pointers — Box, Rc, RefCell, and more.
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>— likeRefCellbut 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-threaded | Multi-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.
RefCellcode 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/RefMutfrom.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;dropthem early if needed. - Using
RefCellacross threads. It isn’t thread-safe and won’t compile in a threaded context. UseMutex(orRwLock) 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
&mutor restructuring works, prefer that — interior mutability adds runtime cost and a panic risk. - Confusing
CellandRefCell.Cellis forCopyvalues viaget/setand never panics;RefCelllends 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 simplerget/setversion forCopyvalues; it never panics.Rc<RefCell<T>>= shared ownership plus mutation, single-threaded;Arc<Mutex<T>>is the thread-safe twin.RefCell/Cellare single-threaded only; useMutex/RwLockacross threads.
Go deeper
- Rust Book - Interior Mutability — How runtime checks fit the model.
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:
- Dereference a raw pointer (
*const T/*mut T). - Call an
unsafefunction (including foreign C functions). - Access or modify a mutable
static(global) variable. - Implement an
unsafetrait. - 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
unsafeto silence a borrow-checker error. It doesn’t do that.unsafeonly unlocks the five specific operations above; a normal ownership error inside anunsafeblock is still an error. If the borrow checker is complaining, fix the design, don’t reach forunsafe. - Making the
unsafeblock 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
unsafeblock should have a comment explaining why the promise holds (“index checked above,” “pointer came from a liveVec”). Future you needs it. - Assuming
unsafeis 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
unsafeunlocks 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
unsafecore — likeVecdoes. - Keep unsafe blocks tiny, document why they’re sound, and prefer a safe alternative whenever one exists.
Go deeper
- The Rustonomicon — Unsafe code and invariants.
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 findaddif it were renamed to something like_ZN3add17h9f....no_manglekeeps the name exactlyadd.
#![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; theunsafe(...)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
Stringknows its length and is not zero-terminated. A C string is just bytes ending in a\0. They are not interchangeable. You convert withstd::ffi::CString(Rust → C) andCStr(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
Stringis 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 withCString/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
unsafementally. 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");
}
Exposing a checksum function for a C caller to link against
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:
#[repr(C)]— without it, Rust is allowed to reorder or pad the fields however it likes, so C might readxwhere Rust puty. It compiles cleanly and then corrupts data at runtime.extern "C"— a bareexterndoesn’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 alwaysunsafe.#[unsafe(no_mangle)] pub extern "C" fnexposes a Rust function to C with an unscrambled name.- Put
#[repr(C)]on any struct that crosses the boundary, and convert strings withCString/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
- Rust Reference - FFI — Extern blocks and ABIs.
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
SystemTimeto measure how long something took. It can jump backward if the system clock is adjusted mid-measurement, silently producing a wrong (orErr-returning) duration.Instantis immune to this — use it for timing. - Expecting
Instantto 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 forchronowhen you need an actual date. - Mixing up
%Mand%min a format string.%mis the month,%Mis minutes — one letter case flips an entire field. Always check a format string against real output once. - Comparing a
Utctime to aLocaltime 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_sincealways succeeds. It returnsErrif the earlier time is actually later — don’t reach for.unwrap()on it without thinking about why thatResultexists.
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.SystemTimeis the real wall clock — use it for timestamps, not for measuring durations, since it can jump if the clock is adjusted.chrono::DateTime<Utc>/Localis the standard type for actual calendar dates:.format(...)to print,DateTime::parse_from_rfc3339(...)to parse.duration_sincereturns aResult, not a bareDuration, because a wall clock can genuinely go backward.- Be explicit about
Utcvs.Local— comparing across them without converting is a classic off-by-hours bug.
Go deeper
- std::time docs — Instant, Duration, SystemTime.
- chrono docs — Calendar dates, timezones, formatting.
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
Regexinside a loop or a frequently-called function. This dominates the runtime cost of “using regex” — always compile once (module-levelLazyLock, or once at startup) and reuse the sameRegexvalue. - Forgetting the raw string prefix. Writing
"\d{4}"instead ofr"\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
regexcrate 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
strmethod would do. A pattern like"starts with http"doesn’t needRegex::new(r"^http")whens.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
regexcrate 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 sameRegexinstead 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
strmethods (split,trim,starts_with,contains) first — regex is for genuine patterns, not fixed substrings.
Go deeper
- regex crate docs — Full pattern syntax and API.
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::IndexedRandomorSliceRandom..choose()lives onIndexedRandom,.shuffle()lives onSliceRandom— neither is directly on slices, so without the right one imported the compiler says something likeno method named \choose` found for reference `&[…]``. - Using a half-open range where you meant inclusive.
rng.random_range(1..6)can never produce6— 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 — checkrand’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 aStdRngin tests instead. - Calling
.choose()on a possibly-empty slice and immediately.unwrap()-ing. It returnsOption<&T>specifically because an empty slice has nothing to choose — handle theNonecase, 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 —
randis 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)needrand::seq::SliceRandomimported — without it, the compiler says the methods don’t exist..choose()returnsOption<&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
- rand crate docs — RNGs, ranges, and distributions.
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
derivefeature onserde.#[derive(Serialize)]only exists if you add serde withfeatures = ["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
displayNameand your field isdisplay_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
Stringfield must be present in the JSON. If the source sometimes drops it, useOption<String>— otherwise every request with that field missing errors out. - Calling
.unwrap()onfrom_strin real code. Deserialization handles untrusted input; unwrapping turns a recoverable “bad JSON” into a crash. Handle theResultinstead. - 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 calleduser_namein the JSON, so it stops looking for a key namednameand stops failing.Option<String>makesbiooptional: present JSON givesSome("..."), missing JSON givesNone. WithoutOption, any JSON lackingbiowould 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
derivefeature, plusserde_jsonfor JSON:cargo add serde --features derive. serde_json::to_string(&value)andserde_json::from_str(text)both returnResult— deserializing untrusted input can fail.- Use
#[serde(rename = ...)]/rename_allto match outside naming, andOption<T>for fields that may be missing.
Go deeper
- Serde docs — Canonical ecosystem docs.
- serde_json docs — JSON support on docs.rs.
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
Clientper request. You lose connection pooling and pay setup costs repeatedly. Build oneClientat 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. Everyreqwestcall here is async; a missing.awaitgives you aFuturethat 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::Clientand 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::Clientis cheap to.clone()(it’s anArcinternally), so sharing it across tasks/handlers is normal.
Go deeper
- reqwest docs — Async HTTP client API.
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
PgPoolat startup and share it. - No live database (or offline cache) at build time. The
query!/query_as!macros need to reach a real database (viaDATABASE_URL) or a saved.sqlxoffline 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-OptionRust field is a compile error withquery!(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
sqlxisn’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. viaArcor 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. PgPoolis cheap to.clone()— it’s a handle to the shared pool, not a new set of connections.
Go deeper
- sqlx docs — Async SQL toolkit.
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 theNonecase 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 returns0, scripts think it succeeded and keep going. Exit non-zero on failure (or returnErrfrommain). - Hand-parsing complex flags. Rolling your own
--optionparser is bug-prone and gives users no--help. Use clap once you have more than one or two arguments. - Forgetting the
--withcargo run.cargo run hellopasseshelloto cargo; you needcargo run -- helloto 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 atargs[1].- Use
.get(1)(notargs[1]) so a missing argument isNone, not a panic. - stdout (
println!) is for real output; stderr (eprintln!) is for status and errors — keep them separate. - Exit
0for success, non-zero for failure; or returnResultfrommainand let Rust do it. - For anything beyond a couple of arguments, use clap — it generates the parser and
--helpfor you.
Go deeper
- Rust Book - Command Line Programs — CLI project example.
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. Calltracing_subscriber::fmt::init()(or yourinit_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
errorcries wolf; logging real failures atdebughides 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
tracingmacros 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
- tracing crate docs — Common Rust tracing ecosystem.
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 withcargo test. This is how test modules stay out of your normal binary entirely.#[cfg(debug_assertions)]— true in a normalcargo build/cargo run, false incargo 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 declaringxin[features]. It doesn’t error, but modern Cargo warnsunexpected 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 = trueon the dependency. Ifserde_jsonisn’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"](nodep:) also implicitly creates a public feature literally namedserde_jsonthat others can enable directly — usually not what you want.dep:serde_jsonenables 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-featuresor 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 runtimeif, 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]inCargo.toml; check it in code with#[cfg(feature = "name")]. optional = trueon a dependency plusfeature = ["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
- Cargo Book - Features — Declaring and using Cargo features.
- Rust Reference - Conditional compilation — Every #[cfg] predicate.
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 ifPATHchanges. 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 withenv!("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 customcfgthe 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 buildpays 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-vvor 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 = ...)insidebuild.rsreports the host, not the target. Use theCARGO_CFG_TARGET_OSenvironment 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.rsat 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=VALUEpairs withenv!("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
- Cargo Book - Build Scripts — What build.rs can do and how Cargo talks to it.
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, andcargo doccollects 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 tocargo 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 fmtwill 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 fmtreformats your whole project to the standard style — never format by hand.//is a normal comment;///is a doc comment thatcargo docturns into documentation.//!documents the enclosing file/module (put it at the top of a file).- Code examples inside doc comments are doctests —
cargo testruns them, so docs stay correct. cargo doc --openbuilds 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
- rustfmt book — Formatting rules and configuration.
- cargo doc — Generate browsable API docs.
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
mainfunction. 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.serdeandaxumare 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 rootCargo.tomlfor 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
pubitems. Forgettingpubgives 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.lockand onetarget/folder. - The workspace root
Cargo.tomlhas[workspace]with amemberslist and (usually) no[package]. - Depend on a local crate with
path = "../other"; you can only use itspubitems. - 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
- Cargo Workspaces — Multi-crate project structure.
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 writeif x.x.len() == 0→ writex.is_empty()(clearer and sometimes faster).return x;on the last line of a function → drop thereturnand the;, just writex.- Looping with an index to read a vector → use
for item in &vecinstead. - 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 fmthandles spacing and layout,cargo clippyhandles style and logic smells. Running one doesn’t do the other’s job. - Fighting the formatter by hand. Manually re-aligning code that
cargo fmtwill 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 ofnames.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 &namesinstead of awhileloop with an index — it’s shorter, can’t go out of bounds, and is the standard Rust way to walk a collection. The manuali = i + 1counter 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 clippyprints 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 == true→x,.len() == 0→.is_empty(), manual index loops →for x in &v. - Run
cargo fmt,cargo clippy, andcargo testas 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 productsPOST /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 mainalone 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)needsvalueto 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:
#[derive(Serialize)]— wrapping a value inJson<T>only works ifTcan be serialized. Without the derive, serde has no idea how to turnProductinto JSON, and the trait bound fails.async fn— Axum handlers must be async so they can pause on slow work without blocking the server. A plainfnwon’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 fnwhose 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>(withT: 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::fscalls have nothing to read or write — there’s no filesystem underneath.std::thread::spawndoesn’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 plainstd::thread).- TCP/UDP sockets aren’t available at all — browsers only expose networking through
fetchandWebSocket, 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-unknownfails immediately if the target was never installed — runrustup target add wasm32-unknown-unknownonce 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-bindgenover many small calls. - Using the wrong
--targetwithwasm-pack build.--target webproduces an ES module you initialize yourself; the default--target bundlerassumes 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(); // 1 — new, 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-unknowntarget (rustup target add wasm32-unknown-unknown), thenwasm-pack buildruns the compile and the bindgen step, producing a ready-to-importpkg/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.tomlneedscrate-type = ["cdylib", "rlib"]in[lib]—wasm-bindgen’s tooling needs thecdylibartifact 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
- wasm-bindgen guide — Rust/JS interop reference.
- Rust and WebAssembly book — End-to-end WASM workflow.
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.