Skip to content
Learn Rust
Theme

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.

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.

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.

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.

Rustsrc/main.rs
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}");
}
$ cargo build
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.

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.

Rustsrc/main.rs
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}");
}
$ cargo build
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.

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.

Rustsrc/main.rs
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.
$ cargo run
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.

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.

Rustsrc/main.rs
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}");
}
$ cargo build
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.

Rust does fewer things silently than most languages. Mixing an integer and a float doesn’t quietly convert one of them:

Rustsrc/main.rs
fn main() {
let count: i32 = 3;
let price: f64 = 2.5;
let total = count * price; // no silent int-to-float conversion
println!("{total}");
}
$ cargo build
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)
Rustsrc/main.rs
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}");
}
$ cargo run
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.

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.

Rustsrc/main.rs
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>();
}
$ cargo run
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.

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.)

Rustsrc/main.rs
fn main() {
let count = 0;
count += 1;
println!("{count}");
}
$ cargo build
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.

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.

Rustsrc/main.rs
// 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));
}
}
$ cargo run
cash
card ending 4242
voucher #7

Add a Crypto(String) kind and leave describe alone, and the build stops:

$ cargo build
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.

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:

Rustsrc/main.rs
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);
}
$ cargo run
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.

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.

Rustsrc/main.rs
fn main() {
let names = vec!["ana", "bo"];
println!("{}", names.lenght());
}
$ cargo build
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.

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.

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.

  • 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 a None still 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.

Copy a program above into a project (Your first project) and change it.

  1. In idea 1, use let port = if ready { 8080 } else { 3000 };. It builds and prints listening on 8080.
  2. In idea 2, add the _ arm. It prints three. Change dice to 9: it still builds, and prints not a dice roll.
  3. In idea 8, add Crypto(String) to Payment and leave describe alone. The build fails with E0004, naming the missing kind.