How Rust wants you to think
Before any syntax, here’s the mindset. Once this clicks, most of Rust’s “weird” rules stop feeling weird.
RememberRust would rather say no today than let your program crash tomorrow.
Rust isn’t low level the way C is
Section titled “Rust isn’t low level the way C is”People often call Rust “low level”, and it can feel that way when you arrive from Python, Go, Java or TypeScript. But in everyday Rust you don’t call free() or track memory by hand. The compiler arranges the cleanup.
What feels low level is that Rust makes you say things other languages leave to the runtime: when data is copied, when it’s moved, who may change it, and when it’s cleaned up. And it checks those choices before your program runs.
| Language | Who frees memory |
|---|---|
| C | You, with free |
| C++ | You, with delete, or a destructor that does it (RAII) |
| Go | Go’s garbage collector, while the program runs |
| Java, Kotlin (JVM), C# | The runtime’s garbage collector, while the program runs |
| TypeScript | The JavaScript engine’s garbage collector, while the program runs |
| Python (CPython) | Reference counting, plus a collector for cycles (gc) |
| Rust | The compiler inserts the cleanup call where the owner goes out of scope (the end of the block it was declared in) |
The full table, with examples, is in Ownership.
The ten ideas behind most rules
Section titled “The ten ideas behind most rules”Most of the rules you’ll meet come from one of these. (Some, like module privacy, lifetime elision or Send and Sync, have their own lessons.) Each one comes with a tiny Rust program and what the compiler really said about it. When an error confuses you later, come back here and ask which idea it’s about.
1. Prove it or refuse
Section titled “1. Prove it or refuse”The compiler has to be sure your code is safe. If it can’t be sure, it says no, even when your program would have worked. Here ready is true, so port gets a value every time. But the compiler only sees an if with no else, and its own note says it doesn’t evaluate the conditions.
fn main() { // true here, and true every time you run it. The compiler still does not use // that to decide whether `port` has a value on every path. let ready = true;
let port: u16; if ready { port = 8080; } println!("listening on {port}");}error[E0381]: used binding `port` is possibly-uninitialized --> src/main.rs:10:29 | 6 | let port: u16; | ---- binding declared here but left uninitialized 7 | if ready { | ----- if this `if` condition is `false`, `port` is not initialized 8 | port = 8080; 9 | } | - an `else` arm might be missing here, initializing `port` 10 | println!("listening on {port}"); | ^^^^ `port` used here but it is possibly-uninitialized | = note: when checking initialization, the compiler describes possible control-flow paths without evaluating whether branch conditions can actually have the values shown
The fix is to make every path give port a value: let port = if ready { 8080 } else { 3000 };. A red squiggle now beats a surprise at 2 a.m.
2. It reads the shape, not the values
Section titled “2. It reads the shape, not the values”The compiler doesn’t run your code, so it doesn’t know what’s inside your variables. You know dice is 1 to 6. The compiler only knows it’s a u8, which can be anything from 0 to 255, so it asks what should happen for the rest.
fn main() { let dice: u8 = 3; // we know it's 1 to 6. The compiler only knows it's a u8.
let word = match dice { 1 => "one", 2 => "two", 3 => "three", 4 => "four", 5 => "five", 6 => "six", }; println!("{word}");}error[E0004]: non-exhaustive patterns: `0_u8` and `7_u8..=u8::MAX` not covered --> src/main.rs:4:22 | 4 | let word = match dice { | ^^^^ patterns `0_u8` and `7_u8..=u8::MAX` not covered | = note: the matched value is of type `u8` help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern, a match arm with multiple or-patterns as shown, or multiple match arms | 10 ~ 6 => "six", 11 ~ 0_u8 | 7_u8..=u8::MAX => todo!(),
Add a last arm, _ => "not a dice roll", and it compiles. More in Match.
3. Every value has one owner
Section titled “3. Every value has one owner”When the owner goes out of scope, the value is cleaned up, at a point you can read in the code. That’s how Rust manages memory without a garbage collector. Assigning a value that isn’t Copy (a String, a Vec) to another variable moves it: the new variable becomes the owner. This program prints when each value is cleaned up, so you can watch it happen.
struct Noisy(&'static str);
// Drop runs when the owner goes away. Here it just prints, so we can watch.impl Drop for Noisy { fn drop(&mut self) { println!("cleaning up {}", self.0); }}
fn main() { let a = Noisy("a"); { let b = Noisy("b"); println!("using {}", b.0); } // b's owner ends here, so b is cleaned up here
let c = a; // a MOVES to c: c is now the one owner println!("using {}", c.0); println!("end of main");} // c is cleaned up here. a is not cleaned up twice: it moved.using b cleaning up b using a end of main cleaning up a
a was cleaned up once, at the end, under its new name c. Leaking on purpose is possible (a cycle of Rcs, the shared-ownership pointer), so the rule is about who cleans up, not a promise that cleanup always happens. More in Stack and heap and Ownership and Moves.
4. Many readers or one writer, never both
Section titled “4. Many readers or one writer, never both”You can have as many read-only references to a value as you like, or one reference that can change it, but not both at the same time. Reading and then writing is fine. Keeping a reader alive while you write is not.
fn main() { let mut list = vec![1, 2, 3];
let total: i32 = list.iter().sum(); // read... list.push(total); // ...then write. Fine: the read is over.
let first = &list[0]; // a reader that is still in use below list.clear(); // a writer at the same time println!("{first}");}error[E0502]: cannot borrow `list` as mutable because it is also borrowed as immutable --> src/main.rs:8:5 | 7 | let first = &list[0]; // a reader that is still in use below | ---- immutable borrow occurs here 8 | list.clear(); // a writer at the same time | ^^^^^^^^^^^^ mutable borrow occurs here 9 | println!("{first}"); | ----- immutable borrow later used here
clear() would leave first pointing at an element that no longer exists, so it’s refused. The same rule is how safe Rust (all code outside unsafe blocks, the marked places where you take over the compiler’s checks) prevents data races at compile time. Logic bugs, like two threads doing the right things in the wrong order, are still yours. (Cell, RefCell and Mutex are types that let you change a value through a shared reference, checking at run time instead: Smart pointers.) More in Mutable Borrows.
5. Less happens behind your back
Section titled “5. Less happens behind your back”Rust does fewer things silently than most languages. Mixing an integer and a float doesn’t quietly convert one of them:
fn main() { let count: i32 = 3; let price: f64 = 2.5; let total = count * price; // no silent int-to-float conversion println!("{total}");}error[E0277]: cannot multiply `i32` by `f64` --> src/main.rs:4:23 | 4 | let total = count * price; // no silent int-to-float conversion | ^ no implementation for `i32 * f64` | = help: the trait `Mul<f64>` is not implemented for `i32` ... (notes pointing into the standard library trimmed)
fn main() { let count: i32 = 3; let price: f64 = 2.5; let total = count as f64 * price; // you ask for the conversion, so you can see it println!("{total}");
let name = String::from("ana"); let copy = name.clone(); // a real copy of the text, and it's visible println!("{name} {copy}");}7.5 ana ana
as and .clone() are written out on purpose, so you can search for conversions and copies of heap data. It isn’t total: integers are copied by plain assignment, ? converts error types, and String::from or vec! allocate without saying so. More in Numbers and Types.
6. If it can fail, it says so
Section titled “6. If it can fail, it says so”In safe code there’s no null, and ordinary errors aren’t exceptions. Something that can fail returns a Result, and something that might be missing returns an Option. You can’t use the value without deciding what happens otherwise, even if you decide on .unwrap(), which panics (stops the program with an error message) when it’s wrong and is easy to search for. Ignoring a Result gets a warning, shown at the top of the output below.
fn main() { // something that can fail returns Result match "42x".parse::<i32>() { Ok(n) => println!("got {n}"), Err(e) => println!("not a number: {e}"), }
// something that might be missing returns Option let names = vec!["ana", "bo"]; match names.get(5) { Some(n) => println!("found {n}"), None => println!("nobody at index 5"), }
// a Result you ignore is a warning (see the build output above the run) "7".parse::<i32>();}warning: unused `Result` that must be used --> src/main.rs:16:5 | 16 | "7".parse::<i32>(); | ^^^^^^^^^^^^^^^^^^ | = note: this `Result` may be an `Err` variant, which should be handled = note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by default help: use `let _ = ...` to ignore the resulting value | 16 | let _ = "7".parse::<i32>(); | +++++++ warning: `can-fail` (bin "can-fail") generated 1 warning not a number: invalid digit found in string nobody at index 5
A bug, like an index out of range, is a panic instead (Errors). More in Option and Errors and Result.
7. Changes are marked
Section titled “7. Changes are marked”Variables can’t change unless you say so with mut. That way you can spot the places where something might change at a glance. (Cell, RefCell and Mutex are the marked exceptions: you see them in the type.)
fn main() { let count = 0; count += 1; println!("{count}");}error[E0384]: cannot assign twice to immutable variable `count` --> src/main.rs:3:5 | 2 | let count = 0; | ----- first assignment to `count` 3 | count += 1; | ^^^^^^^^^^ cannot assign twice to immutable variable | help: consider making this binding mutable | 2 | let mut count = 0; | +++
The compiler even shows you where to add mut. More in Variables.
8. Wrong states should be hard to write
Section titled “8. Wrong states should be hard to write”An enum lists every kind of thing a value can be, and each kind carries only its own data. A card payment has a card number, cash doesn’t. A match has to handle every kind, so adding a new kind later shows you the places that need updating.
// A payment is exactly one of these. Each kind carries only its own data.enum Payment { Cash, Card { last4: String }, Voucher(u32),}
fn describe(p: &Payment) -> String { // match must cover every kind. Add a new kind later and this stops compiling // until you handle it. match p { Payment::Cash => "cash".to_string(), Payment::Card { last4 } => format!("card ending {last4}"), Payment::Voucher(code) => format!("voucher #{code}"), }}
fn main() { let payments = [ Payment::Cash, Payment::Card { last4: "4242".to_string(), }, Payment::Voucher(7), ]; for p in &payments { println!("{}", describe(p)); }}cash card ending 4242 voucher #7
Add a Crypto(String) kind and leave describe alone, and the build stops:
error[E0004]: non-exhaustive patterns: `&Payment::Crypto(_)` not covered --> src/main.rs:10:11 | 10 | match p { | ^ pattern `&Payment::Crypto(_)` not covered | note: `Payment` defined here --> src/main.rs:2:6 | 2 | enum Payment { | ^^^^^^^ ... 6 | Crypto(String), | ------ not covered = note: the matched value is of type `&Payment` help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown | 13 ~ Payment::Voucher(code) => format!("voucher #{code}"), 14 ~ &Payment::Crypto(_) => todo!(),
A _ arm switches this help off for the kinds it catches (Enums). More in Enums.
9. Nice code shouldn’t cost extra
Section titled “9. Nice code shouldn’t cost extra”Rust aims for abstractions that cost no more than the code you’d write by hand. This iterator chain does the same job as the loop under it, and the two agree:
fn main() { // reads like a pipeline: nothing is built between the steps, no temporary list let total: u64 = (1..=1_000_000u64) .filter(|n| n % 3 == 0) .map(|n| n * 2) .sum(); println!("pipeline: {total}");
// the same job as a loop written by hand let mut by_hand = 0u64; for n in 1..=1_000_000u64 { if n % 3 == 0 { by_hand += n * 2; } } println!("by hand: {by_hand}"); assert_eq!(total, by_hand);}pipeline: 333333666666 by hand: 333333666666
The Rust Book says iterators “get compiled down to roughly the same code as if you’d written the lower-level code yourself” (Loops vs. Iterators). That is about optimized builds: cargo run makes a debug build, so this page times nothing. In debug and release builds alike, each step is a lazy adaptor (it does no work until something asks for the result) and no temporary list is built (what a chain compiles to). More in Iterators.
10. The compiler is your pair programmer
Section titled “10. The compiler is your pair programmer”Rust’s errors are often long because they’re trying to help. They show where things went wrong and often suggest a fix. Read each one top to bottom, starting with the first error, and look for the help: lines. The error index explains each code.
fn main() { let names = vec!["ana", "bo"]; println!("{}", names.lenght());}error[E0599]: no method named `lenght` found for struct `Vec<&str>` in the current scope --> src/main.rs:3:26 | 3 | println!("{}", names.lenght()); | ^^^^^^ | help: there is a method `len` with a similar name | 3 - println!("{}", names.lenght()); 3 + println!("{}", names.len());
For style suggestions on code that already compiles, run cargo clippy. There’s an example in Your first project.
Why the compiler seems so strict
Section titled “Why the compiler seems so strict”Most languages check some things before running and leave the rest to the program while it runs. Rust asks a harder question about ownership, borrowing and missing cases: can I prove this can never go wrong?
That’s why perfectly reasonable code sometimes fails, like the first two examples above. The compiler isn’t saying your code is wrong. It’s saying it can’t prove your code is right. Usually there’s a small change that lets it prove it, and that’s what the gotchas at the end of each lesson teach.
The same job in your language
Section titled “The same job in your language”You may know some of this already. These are the same three small mistakes, run in each language in this site’s image (Go 1.27, Java 25, Kotlin 2.4, .NET 10, TypeScript 7 with --strict, Python 3.13, gcc 14 with -Wall -Wextra):
| Language | A variable that may not be set | int times float |
Changing what you didn’t mark |
|---|---|---|---|
| Rust | Refused (E0381) | Refused (E0277) | Refused (E0384); mut is the mark |
| Go | Can’t happen: starts at zero | Refused: mismatched types | Allowed; only const is refused |
| Java | Refused | Allowed | Allowed; only final is refused |
| Kotlin | Refused | Allowed | val refused, var allowed |
| C# | Refused (CS0165) | Allowed | Allowed; only const is refused |
| Python | Runs; NameError only if the branch is skipped |
Allowed | Allowed, even to another type |
| TypeScript | Refused (TS2454) | Allowed (one number type) |
const refused, let allowed |
| C, C++ | Compiles with no warning here (the C++ run is the same C program built with g++) | Allowed | Allowed; only const is refused |
So the first example isn’t new: Java, Kotlin, C# and TypeScript refuse it too. What Rust adds is the same kind of check for who owns a value and who may change it.
What you get, and what it costs
Section titled “What you get, and what it costs”- You get: in safe code, no use-after-free, no dangling references, no data races, and no garbage collector, so no collector pauses. (
unwrap()on aNonestill panics.) - You pay: a learning curve, and errors up front instead of later. Some correct programs are refused until you restructure them. The rest of this site exists to make that curve gentler, and Why Rust weighs the costs.
Try it yourself
Section titled “Try it yourself”Copy a program above into a project (Your first project) and change it.
- In idea 1, use
let port = if ready { 8080 } else { 3000 };. It builds and printslistening on 8080. - In idea 2, add the
_arm. It printsthree. Changediceto9: it still builds, and printsnot a dice roll. - In idea 8, add
Crypto(String)toPaymentand leavedescribealone. The build fails with E0004, naming the missing kind.
Read more
Section titled “Read more”- What is Ownership? and References and Borrowing, the Rust Book: ideas 3 and 4 in full.
- The Drop trait (Rust Book) and RAII (Rust by Example): the cleanup calls. Error Handling:
Resultand panics. - The error index: each code on this page, such as E0381 and E0004.
Learn Rust is an independent project, not affiliated with the Rust Project or the Rust Foundation. Code and text are under the MIT licence.Source on GitHub.