All Tutorials

Rust for Python Developers

A side-by-side tutorial — every concept shown in Python first, then its Rust equivalent

Rust Python Ownership Cargo Systems

Introduction

What Rust actually is

Rust is a systems programming language that gives you C-level control over memory and performance without a garbage collector — instead, the compiler enforces memory safety at compile time through its ownership and borrowing rules. There's no runtime, no VM, and no GC pause: a compiled Rust binary runs like C or C++, but a whole category of bugs (use-after-free, data races, null-pointer dereferences) becomes a compile error instead of a crash.

Why learn it through Python

This tutorial assumes no systems-programming background — only Python. Every concept is shown in Python first, then its Rust equivalent, so instead of learning Rust's rules in the abstract you can map them onto behavior you already know: what Python's garbage collector cleans up at runtime, Rust's borrow checker verifies at compile time; what a Python list or dict does dynamically, a Rust Vec or HashMap does with types checked ahead of time. Where Rust has no Python equivalent at all — ownership and borrowing chief among them — that gets called out explicitly rather than glossed over.

The core difference in one sentence: Python figures things out at runtime; Rust proves everything is correct at compile time.

Toolchain Comparison

Python Rust Purpose
python rustc Compiler / interpreter
pip / uv cargo Package manager + build tool
pyenv / uv python rustup Manage language versions
venv / .venv built into cargo Isolated environments
pyproject.toml Cargo.toml Project manifest
uv.lock Cargo.lock Lockfile
ruff cargo clippy Linter
black / ruff format cargo fmt Formatter
pytest cargo test Test runner
pydoc / mkdocs cargo doc Documentation
PyPI crates.io Package registry

1 · Hello World

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

Key differences:

  • Rust needs a fn main() entry point — Python just runs top to bottom
  • println! is a macro (the ! is the tell) — macros are expanded at compile time
  • Every statement ends with ;
  • Run with cargo run, not python hello.py

2 · Variables

Python is dynamic and mutable by default; Rust is statically typed and immutable by default.

Python
name = "Alice"
age = 30
age = 31         # reassign freely
pi = 3.14
is_valid = True
Rust
let name = "Alice";      // type inferred as &str
let age = 30;            // inferred as i32
// age = 31;             // compile error! age is immutable

let mut age = 30;        // mut makes it mutable
age = 31;                // now fine

let pi: f64 = 3.14;      // explicit type annotation
let is_valid: bool = true;
Every value bound above — age, pi, is_valid — is a scalar: a single value with no internal structure. 3. Data Types introduces Rust's scalar-vs-compound split properly.

Key differences:

  • let declares a variable; let mut makes it mutable
  • Types are inferred most of the time, but you can annotate explicitly
  • Python variables are always reassignable; Rust variables are immutable unless you say mut
  • Shadowing — you can re-declare with let to change type:
Python
x = 5
x = x + 1        # rebinds the same name — there is no second variable
x = "now a str"  # type changes silently; nothing was declared to violate
Rust
let x = 5;
let x = x + 1;       // new variable, shadows the old one
let x = "now a str"; // even change the type — this is valid
No Python equivalent: Python's Final is a type-checker hint only — nothing stops age = 31 at runtime, mypy just complains. Rust's immutability is enforced by the compiler itself; there is no way to reassign a non-mut binding, checked or not.

3 · Data Types

Rust splits its types into two families. Scalar types hold one single value — integers, floats, bool, char. Compound types group several values into one — tuples, arrays, and, at the zero-element extreme, (). Python never draws this line explicitly (every value is just "an object"), but the same shape is there implicitly: int/float/bool behave like scalars, tuple/list behave like compounds.

Scalar Types — one value, nothing to unpack

Python Rust Notes
int i8 i16 i32 i64 i128 isize Signed integers — default is i32
int (non-negative) u8 u16 u32 u64 u128 usize Unsigned integers
float f32 f64 Default is f64
bool bool true / false (lowercase)
one-character str char Python has no distinct type here — see the char deep-dive later in this section

Compound and Collection Types — grouping values together

Python Rust Notes
str &str Immutable string slice — borrowed
str (owned) String Owned, heap-allocated, growable
bytes Vec<u8> Byte buffer
None () (unit type) "No meaningful value" — technically Rust's zero-element compound type
list Vec<T> Growable array
tuple (T, U, V) Fixed-size, mixed types — Rust's other compound type
dict HashMap<K, V> Hash map
set HashSet<T> Hash set
If you want the precise terms: Rust's own vocabulary only calls integers/floats/bool/char scalar, and only tuples/arrays/() compoundString, Vec, HashMap, and friends are a separate bucket usually just called collections. Python has no equivalent vocabulary at all for any of this.

&str vs String — the most common confusion for newcomers:

Python
# Python: one string type
greeting = "hello"
greeting = greeting + " world"   # creates a new string
Rust
// &str — a reference to existing string data (like a view/slice)
let s1: &str = "hello";          // lives in the binary, borrowed

// String — owned, heap-allocated, can grow
let mut s2: String = String::from("hello");
s2.push_str(" world");           // mutate in place

// Convert between them
let s3: &str = &s2;              // String → &str (borrow it)
let s4: String = s1.to_string(); // &str  → String (clone into heap)
Rule of thumb: Use &str for function parameters (read-only), use String when you need to own or modify the data.

Types Python Has No Equivalent For

char — a single Unicode scalar

Python has no character type; s[0] returns a one-element str. Rust's char is a distinct 4-byte type.

Python
c = "hello"[0]        # still a str, just length 1
type(c)               # <class 'str'>
Rust
let c: char = 'h';               // single quotes = char, double = &str
let c2: char = '🦀';             // 4 bytes, holds any Unicode scalar
let count = "hello".chars().count();   // 5 chars
let bytes = "hello".len();             // 5 bytes — NOT always the same number

Fixed-size arrays — length is part of the type

Python
buf = [0] * 32        # a list that happens to have 32 items; can grow
buf.append(1)         # now 33 — nothing stops you
Rust
let buf: [u8; 32] = [0; 32];     // exactly 32 u8s, on the stack, cannot grow
// buf.push(1);                  // no such method — arrays have fixed length

fn checksum(block: [u8; 32]) -> u8 { /* ... */ }
// checksum(&[0u8; 16]);         // compile error: expected [u8; 32], found [u8; 16]

A wrong-length buffer is a compile error, not a runtime IndexError.

The newtype pattern — type safety that survives to runtime

Python's NewType is erased by the interpreter; nothing stops you passing the wrong one.

Python
from typing import NewType

Meters = NewType("Meters", float)
Feet = NewType("Feet", float)

def altitude(m: Meters) -> str: ...

altitude(Feet(1000.0))    # type checker complains; Python runs it happily
Rust
struct Meters(f64);        // tuple struct — a genuinely distinct type
struct Feet(f64);

fn altitude(m: Meters) -> String { format!("{} m", m.0) }

// altitude(Feet(1000.0));   // compile error: expected Meters, found Feet
altitude(Meters(305.0));     // and it costs zero bytes at runtime

The never type !

A function that never returns has type !, which coerces to anything. Python has no way to tell the type checker "control flow stops here" from a return type alone.

Python
from typing import NoReturn

def fail(msg: str) -> NoReturn:
    raise ValueError(msg)        # NoReturn is a promise to the checker, not a check

def get(value: int | None) -> int:
    match value:
        case int(n): return n
        case None:   return fail("required")  # a call, not a type — must be returned
Rust
fn fail(msg: &str) -> ! {
    panic!("{msg}");          // never returns
}

let x: i32 = match input {
    Some(n) => n,
    None => fail("required"),  // `!` fits where i32 is expected
};
Rust type Python Gap
char one-char str No distinct character type
[T; N] list Length isn't in the type
struct Meters(f64) NewType Erased at runtime
! NoReturn (hint only) Not enforced
u8u128 int No width or signedness

4 · Functions

Python
def add(a: int, b: int) -> int:
    return a + b

result = add(3, 4)
print(result)  # 7
Rust
fn add(a: i32, b: i32) -> i32 {
    a + b        // no semicolon = implicit return of this expression
}

fn main() {
    let result = add(3, 4);
    println!("{result}");  // 7
}

Key differences:

  • Parameter types and return type are mandatory in Rust (no dynamic dispatch)
  • The last expression in a function without a semicolon is the return value
  • return exists but is idiomatic only for early returns
Python
# explicit early return
def divide(a: float, b: float) -> float:
    if b == 0.0:
        return 0.0           # early return with keyword
    return a / b             # no implicit return — drop `return` and you get None

divide(3, 0)                 # ints pass fine; the annotations are never checked
Rust
// explicit early return
fn divide(a: f64, b: f64) -> f64 {
    if b == 0.0 {
        return 0.0;          // early return with keyword
    }
    a / b                    // implicit return
}

Function Features Python Doesn't Have

Generics with trait bounds — checked at compile time, specialised per type

Python's TypeVar is erased: the interpreter never verifies that T actually supports >. Rust's bounds are a contract the compiler enforces at the definition site, then it generates a separate machine-code copy per concrete type (monomorphization) — so generics cost nothing at runtime.

Python
from typing import TypeVar

T = TypeVar("T")

def largest(items: list[T]) -> T:
    biggest = items[0]
    for item in items:
        if item > biggest:      # nothing guarantees T supports `>`
            biggest = item
    return biggest

largest([object(), object()])   # TypeError at runtime
Rust
// T must implement PartialOrd — enforced here, before any caller exists
fn largest<T: PartialOrd>(items: &[T]) -> &T {
    let mut biggest = &items[0];
    for item in items {
        if item > biggest {     // legal only because of the bound
            biggest = item;
        }
    }
    biggest
}

largest(&[3, 7, 2]);            // compiles a u/i32 version
largest(&["a", "c", "b"]);      // compiles a &str version
// largest(&[some_struct]);     // compile error unless it derives PartialOrd

Multiple bounds read better with a where clause:

Python
from typing import Protocol

class DisplayClone(Protocol):                # `Display + Clone` must fuse into one type
    def clone(self) -> "DisplayClone": ...   # __str__ comes free on every object

def describe[T: DisplayClone](item: T) -> str:   # one bound only — no `+`, no `where`
    return f"{item.clone()}"                     # nothing checks clone() until it runs
Rust
fn describe<T>(item: T) -> String
where
    T: std::fmt::Display + Clone,
{
    format!("{}", item.clone())
}

const fn — functions that run at compile time

Python computes everything at runtime. A const fn result can be baked into the binary and used where a constant is required.

Python
BUFFER_SIZE = 8 * 1024        # computed at import time, every run
Rust
const fn buffer_size(kb: usize) -> usize {
    kb * 1024
}

const BUF: usize = buffer_size(8);   // evaluated by the compiler
let arr = [0u8; buffer_size(8)];     // usable as an array length — runtime fn can't be

Zero-cost inlining and no per-call overhead

Rust function calls are static (resolved at compile time) and routinely inlined. Python resolves every call through dictionary lookups on the object and module. This is why the iterator chains in 13. Closures & Iterators compile to the same code as a hand-written loop.

Function pointers — fn as its own concrete type

Every Python function is the same kind of object — a def and a lambda are both instances of the same callable type. Rust has no single "function" type: a bare fn item, a function pointer, and a closure are three different things.

Python
def add_one(x: int) -> int:
    return x + 1

# a function is just an object, like any other
ops = [add_one, lambda x: x * 2]
Rust
fn add_one(x: i32) -> i32 { x + 1 }

// fn(i32) -> i32 is a concrete type — a function POINTER, not a closure.
// It can't capture anything, which is exactly what makes it safe to store
// and copy around without a Box or a lifetime.
let op: fn(i32) -> i32 = add_one;
println!("{}", op(5));   // 6

// Mixing fn items with closures needs a common denominator — Fn, see 12. Closures & Iterators
let ops: Vec<Box<dyn Fn(i32) -> i32>> = vec![Box::new(add_one), Box::new(|x| x * 2)];
No Python equivalent: Python has one universal callable type — you never choose between "a function," "a function pointer," and "a closure," they're all just objects. Rust's fn pointer type is what lets a non-capturing function be stored uniformly (a struct field, a Vec) without paying for the capture machinery closures need.

The other function forms — where each is actually covered

Rust has a few more function shapes that don't belong in this section, but are worth knowing exist:

Form What makes it different Covered in
Closures (Fn / FnMut / FnOnce) can capture their environment; each has a unique, compiler-generated type 13. Closures & Iterators
impl Trait in argument/return position caller or compiler picks the concrete type; in return position it hides an unnameable type at zero cost 12. Traits (Python Protocols and ABCs)
async fn doesn't run on call — returns a suspended state machine that a runtime must poll 17. Concurrency
Diverging functions (-> !) never returns at all; the never type coerces to anything 3. Data Types
unsafe fn / extern "C" fn callers must use unsafe; ABI-compatible with C 22. Unsafe Rust
The reverse gap: Rust has no default arguments, no keyword arguments, and no *args/**kwargs. See 25. What Python Has That Rust Doesn't.

5 · Control Flow

if / else

Python
x = 10
if x > 5:
    print("big")
elif x == 5:
    print("five")
else:
    print("small")
Rust
let x = 10;
if x > 5 {
    println!("big");
} else if x == 5 {
    println!("five");
} else {
    println!("small");
}

Rust if is an expression — it returns a value:

Python
# Python ternary
label = "even" if x % 2 == 0 else "odd"
Rust
// Rust if-expression (much cleaner than ternary)
let label = if x % 2 == 0 { "even" } else { "odd" };

Loops

Python
# while
i = 0
while i < 5:
    print(i)
    i += 1

# for range
for i in range(5):
    print(i)

# for over collection
names = ["Alice", "Bob"]
for name in names:
    print(name)

# loop with break and value — not native, use while True
result = None
for i in range(100):
    if i * i > 50:
        result = i
        break
Rust
// while
let mut i = 0;
while i < 5 {
    println!("{i}");
    i += 1;
}

// for range (0..5 is exclusive end, 0..=5 is inclusive)
for i in 0..5 {
    println!("{i}");
}

// for over collection
let names = vec!["Alice", "Bob"];
for name in &names {
    println!("{name}");
}

// loop — Rust's infinite loop; can return a value with break
let result = loop {
    i += 1;
    if i * i > 50 {
        break i;         // break with a value — returned from loop
    }
};

Control Flow Rust Has That Python Doesn't

Python 3.10+ does have match, so the gap isn't the keyword — it's everything around it. These five constructs have no real Python equivalent.

1. match is an exhaustive expression

Python's match is a statement: it can't produce a value, and forgetting a case is a runtime problem. Rust's is an expression the compiler refuses to build until every case is covered.

Python
# Python — match is a statement, so you assign inside each branch
match status:
    case 200: label = "ok"
    case 404: label = "missing"
# forget a case and nothing complains here — `label` is simply unbound,
# and you find out at the next line with a NameError
print(label)
Rust
// Rust — match evaluates to a value
let label = match status {
    200 => "ok",
    404 => "missing",
    _   => "other",     // delete this arm → compile error, not a runtime surprise
};

Exhaustiveness pays off most on enums: add a variant to an enum and every match on it fails to compile until you handle it. Add a member to a Python Enum and nothing breaks — until production. See 15. Pattern Matching for the pattern syntax itself.

2. Everything is an expression

if, match, loop, and even bare { } blocks all evaluate to a value. Python's if / for / while evaluate to nothing.

Python
# Python — needs a temp variable and a function to scope the setup
def _build_config():
    base = load_defaults()
    return base.merge(env_overrides())

config = _build_config()
Rust
// Rust — a block is an expression; intermediates stay scoped inside it
let config = {
    let base = load_defaults();
    base.merge(env_overrides())   // last expression, no semicolon = block's value
};
// `base` doesn't exist out here

3. loop with a value-carrying break

Shown above: break i hands a value out of the loop. Python needs while True plus a sentinel variable initialised to None before the loop.

4. Labeled loops — break and continue an outer loop

Python
# Python — no labeled break. You need a flag, an exception, or a helper function.
found = None
for i in range(10):
    for j in range(10):
        if i * j > 20:
            found = (i, j)
            break
    if found:            # the awkward second check
        break
Rust
// Rust — name the loop, break it directly
let found = 'outer: loop {
    for i in 0..10 {
        for j in 0..10 {
            if i * j > 20 {
                break 'outer Some((i, j));   // exits the labeled loop with a value
            }
        }
    }
    break None;
};

// `continue 'label` works too — skip to the next outer iteration
'rows: for row in &grid {
    for cell in row {
        if cell.is_invalid() {
            continue 'rows;                  // abandon this row entirely
        }
    }
}

5. if let, while let, and let ... else

Python's walrus operator (:=) binds a value but cannot destructure. Rust binds by pattern, in three control-flow shapes:

Python
# Python's closest attempts
if (m := PATTERN.match(line)):    # binds, but can't destructure into fields
    print(m.group(1))

while (item := stack.pop() if stack else None) is not None:
    print(item)               # no `while let` — you fake it with a sentinel

user = find_user(id)          # no `let ... else` — guard clauses are manual
if user is None:
    raise ValueError("no such user")
Rust
// if let — run the branch only if the pattern matches, binding as it goes
if let Some(name) = maybe_name {
    println!("{name}");
}

// while let — loop as long as the pattern keeps matching
while let Some(top) = stack.pop() {
    println!("{top}");      // drains the stack, no is_empty() check needed
}

// let ... else — bind or diverge. Keeps the happy path unindented.
let Some(user) = find_user(id) else {
    return Err("no such user".into());   // must diverge: return / break / panic
};
println!("{}", user.name);  // `user` is in scope here, zero nesting
Rust Python equivalent Gap
match as expression none — statement only Can't assign from a match
Exhaustiveness check none Missing case = runtime bug
Block / if / loop as expression none Needs temp vars or helper fns
break value while True + sentinel Extra variable, wider scope
break 'label / continue 'label flag, exception, or refactor No labeled break at all
if let if (x := ...) Walrus can't destructure
while let sentinel loop No pattern-driven loop
let ... else manual if None: raise No diverging bind

6 · Owner and Borrower — The Concept Python Doesn't Have

Python manages memory with a garbage collector (reference counting). You never think about who "owns" an object. Rust has no GC — memory is managed at compile time through ownership rules, built on two roles every value can be in:

  • The owner is the one binding responsible for a value. It holds the data, and when it goes out of scope, the value is dropped (freed) — automatically, deterministically, with no GC involved. A value has exactly one owner at a time.
  • A borrower is a reference (&T for read-only, &mut T for read-write) that gets temporary access to the owner's data without taking any responsibility for it. A borrower never drops the value and never becomes the owner — when its scope ends, the owner still owns the value, exactly as before.

Python has neither role. There's no single "owner" of an object — the GC just counts references and doesn't care which one you call the "real" one — and there's no concept of borrowing something temporarily: every reference to a Python object is equally permanent and equally able to mutate it, forever, with nothing to hand back.

Three rules: 1. Every value has exactly one owner 2. When the owner goes out of scope, the value is dropped (freed) 3. There can be many immutable borrowers or one mutable borrower — never both at once

Python
import copy

# Rule 1: python has no owner — only names pointing at one object
s1 = "hello"
s2 = s1                            # nothing moves; s1 stays perfectly valid
print(s1, s2)                      # hello hello
print(id(s1) == id(s2))            # True — one object, two names: the aliasing rust removes

# "Copy types" aren't a category here — ints just happen to be immutable
x1 = 5
x2 = x1
print(x1, x2)                      # 5 5

# with a mutable object, that aliasing is what it costs you
a = ["hello"]
b = a                              # still one list, now reachable through two names
b.append("world")
print(a)                           # ['hello', 'world'] — `a` changed and nothing announced it

# copy.deepcopy — the explicit clone, and the only thing here that isn't aliasing
c = copy.deepcopy(a)
print(a is c, a == c)              # False True — different objects, equal contents

# no borrowing: passing an argument just makes a third permanent name
def print_list(items):             # not a borrower — a co-owner, for as long as it likes
    print(items)
def append(items):                 # nothing in the signature admits that this one writes
    items.append("world")

s = ["hello"]                      # no owner, no `mut`, nothing to lend
print_list(s)                      # ['hello']
append(s)                          # no `&mut` at the call site — you find out by reading the body
print(s)                           # ['hello', 'world'] — the caller's list, mutated
Rust
// Rule 1: one owner
let s1 = String::from("hello");
let s2 = s1;              // ownership MOVED to s2
// println!("{s1}");      // compile error: s1 no longer valid

// Copy types are the exception — no move happens at all
let x1 = 5;
let x2 = x1;              // x1 is bitwise COPIED, not moved
println!("{x1} {x2}");    // both still valid — i32 implements Copy

// Cloning — like Python's copy.deepcopy
let s1 = String::from("hello");
let s2 = s1.clone();      // deep copy — both s1 and s2 are valid
println!("{s1} {s2}");

// Borrowing — pass a reference, don't transfer ownership
fn print_str(s: &String) {          // s here is a BORROWER — read-only, temporary
    println!("{s}");
}                                   // borrower's scope ends here; nothing is dropped

let s = String::from("hello");      // s here is the OWNER
print_str(&s);                      // lend a borrow to print_str
println!("{s}");                    // s (the owner) still valid — the borrow already ended

// Mutable borrow
fn append(s: &mut String) {         // s here is a BORROWER — exclusive, temporary write access
    s.push_str(" world");
}

let mut s = String::from("hello");  // s here is the OWNER
append(&mut s);                     // lend a mutable borrow — only one allowed at a time
println!("{s}");                    // "hello world" — the owner sees the mutation
No Python equivalent: whether a value moves or copies on assignment is a static, per-type fact the compiler tracks (the Copy trait — implemented by i32, bool, char, and tuples of these, never by String or Vec). Python has no move semantics at all, so it has nothing to carve an exception out of — every assignment just shares a reference to the same object.

Why this matters for Python developers:

Python
# Python: this just works — GC handles it
def process(items):
    result = items          # both point to the same list
    result.append(99)
    return result           # original items also modified!
Rust
// Rust: the compiler forces you to be explicit
fn process(items: Vec<i32>) -> Vec<i32> {   // takes ownership
    let mut result = items;
    result.push(99);
    result                                   // returns ownership
}

// Or borrow it instead
fn process_borrow(items: &[i32]) -> Vec<i32> {
    let mut result = items.to_vec();         // make a copy
    result.push(99);
    result
}

Making Ownership Shared — Rc, Arc, RefCell

Every Python object is effectively an Rc<RefCell<T>>: reference-counted, and mutable by anyone holding it. Rust makes you opt in to each half separately, so you always know what you're paying for.

Rust What it gives you Cost Python
Box<T> single owner, heap-allocated none beyond the allocation every object
Rc<T> many owners, single-threaded refcount increment every object
Arc<T> many owners, thread-safe atomic refcount every object (refcount; atomic on free-threaded builds)
RefCell<T> mutate through a shared reference runtime borrow check every object
Mutex<T> mutate across threads lock threading.Lock (advisory)
Python
# Python — sharing is implicit and free-looking
shared = {"count": 0}
a = shared
b = shared
b["count"] += 1
print(a["count"])         # 1 — a and b are the same object
Rust
use std::rc::Rc;
use std::cell::RefCell;

// Rc = shared ownership. RefCell = interior mutability.
let shared = Rc::new(RefCell::new(0));
let a = Rc::clone(&shared);          // refcount 2 — cheap, no deep copy
let b = Rc::clone(&shared);          // refcount 3

*b.borrow_mut() += 1;
println!("{}", a.borrow());          // 1
println!("{}", Rc::strong_count(&shared));  // 3

The catch Python doesn't have: RefCell moves the borrow check from compile time to runtime. Two simultaneous borrow_mut() calls panic. That's the price of escaping the static checker — and it's why RefCell is a last resort, not a default.

Python

No Python equivalent. Nothing tracks borrows, at compile time or runtime. Two names for the same list can both mutate it, and Python will never object.

Rust
let cell = RefCell::new(5);
let first = cell.borrow_mut();
// let second = cell.borrow_mut();   // panics: already mutably borrowed

Slices — borrowed views with no copy

Python
nums = [1, 2, 3, 4, 5]
window = nums[1:4]        # a NEW list — copies the elements
window[0] = 99            # original untouched
Rust
let nums = vec![1, 2, 3, 4, 5];
let window: &[i32] = &nums[1..4];   // a VIEW — zero copy, zero allocation
println!("{window:?}");             // [2, 3, 4]

// Because it borrows, the compiler stops you invalidating it:
// nums.push(6);                    // error: cannot borrow `nums` as mutable
println!("{window:?}");             // ...while `window` is still alive

Taking &[T] instead of Vec<T> as a parameter is the single most common Rust idiom a Python developer misses — it accepts arrays, Vecs, and other slices without copying anything.

Borrowing raises a question Python never has to answer: how long is this reference valid? That's 16. Lifetimes.

7 · Collections

The difference before any syntax: Python's containers mix types freely — a list, dict, or tuple can hold anything, in any position, and nothing checks. Rust's containers cannot, with exactly one exception:

Container Type mix allowed? Enforced how
Python list / dict / tuple any mix, any position not enforced — a runtime convention at best
Rust Vec<T> no — one T for the whole Vec compiler, at compile time
Rust [T; N] no — one T for the whole array compiler, at compile time
Rust HashMap<K, V> no — one K, one V for the whole map compiler, at compile time
Rust (T1, T2, …) tuple yes — the one exception compiler still fixes each position's type

Vec (Python list)

Python
nums = [1, 2, 3]
nums.append(4)
nums.pop()
print(nums[0])
print(len(nums))
nums2 = [x * 2 for x in nums]   # list comprehension
Rust
let mut nums: Vec<i32> = vec![1, 2, 3];
nums.push(4);
nums.pop();
println!("{}", nums[0]);
println!("{}", nums.len());

// iterator chain replaces list comprehension
let nums2: Vec<i32> = nums.iter().map(|x| x * 2).collect();

Key difference — homogeneous vs heterogeneous: a Python list holds anything, mixed:

Vec<T> can only ever hold one type T, fixed once and checked by the compiler — there's no T that fits 1, "two", and 3.0 at once:

Python
mixed = [1, "two", 3.0, [4], None]   # completely normal
Rust
// let mixed = vec![1, "two", 3.0];   // compile error: no single type fits every element

Want a Python-list-like "bag of different types" that's still growable? Rust has no free way to get one — pick a deliberate trade-off instead:

Python
# Closed set — a union alias, but it is documentation: nothing enforces it
Value = int | str | float
mixed: list[Value] = [1, "two", 3.0]

# Open set — the default. Every list already is this; no declaration needed
mixed: list[object] = [1, "two", 3.0]
for item in mixed:              # the two lists are indistinguishable at runtime
    print(item)                 # each element was always a separate heap object
Rust
// Closed set of shapes — no extra allocation beyond the Vec, matched exhaustively
enum Value { Int(i32), Text(String), Float(f64) }
let mixed: Vec<Value> = vec![Value::Int(1), Value::Text("two".into()), Value::Float(3.0)];

// Open set — any type implementing the trait, one heap allocation per element
let mixed: Vec<Box<dyn std::fmt::Display>> =
    vec![Box::new(1), Box::new("two"), Box::new(3.0)];
for item in &mixed { println!("{item}"); }
The enum costs nothing extra at runtime but only accepts variants you declared up front (see 10. Enums); the trait object accepts anything but pays for a heap allocation and a dynamic dispatch per element (see 12. Traits).

Arrays — fixed length, no Python equivalent

Python's list is always growable — there's no separate "fixed-size, same-type" container. Rust's array [T; N] bakes the length into the type itself.

Python — a fixed length is a convention, never a guarantee

Python
buf = [0, 0, 0, 0, 0]
buf.append(1)          # now 6 — nothing stops you
Rust
let buf: [i32; 5] = [0, 0, 0, 0, 0];
let filled: [i32; 5] = [0; 5];    // shorthand: 5 copies of 0

for x in &buf {                   // iterate by reference
    println!("{x}");
}
println!("{}", buf.len());        // 5, known at compile time
// buf.push(1);                   // no such method — length is fixed
Arrays live on the stack, not the heap. See 3. Data Types for how a fixed length turns a wrong-size buffer into a compile error instead of a runtime IndexError.

Slices — a borrowed view, not its own container

Python's nums[1:4] copies the elements into a new list. Rust's &[T] is a view into a Vec, an array, or another slice — zero copy, zero allocation.

Python
nums = [1, 2, 3, 4, 5]
arr = (1, 2, 3, 4, 5)             # tuple — fixed length, still heap-allocated

from_list = nums[1:4]             # a new list, not a view — [2, 3, 4]
from_tuple = arr[1:4]             # a new tuple — copied again
memoryview(b"hello")[1:4]         # the one zero-copy slice: buffers only, not lists
Rust
let nums = vec![1, 2, 3, 4, 5];
let arr: [i32; 5] = [1, 2, 3, 4, 5];

let from_vec: &[i32] = &nums[1..4];   // view into the Vec — [2, 3, 4]
let from_arr: &[i32] = &arr[1..4];    // view into the array — same slice type
&[T] is the type to reach for in function parameters — it accepts a Vec, an array, or another slice without copying anything. Full treatment (why the borrow checker gets involved, how this interacts with lifetimes) lives in 6. Owner and Borrower — The Concept Python Doesn't Have.

HashMap (Python dict)

Python
scores = {"Alice": 100, "Bob": 85}
scores["Charlie"] = 90
print(scores.get("Alice"))
print(scores.get("Dave", 0))     # default value

for name, score in scores.items():
    print(f"{name}: {score}")
Rust
use std::collections::HashMap;

let mut scores: HashMap<&str, i32> = HashMap::new();
scores.insert("Alice", 100);
scores.insert("Bob", 85);
scores.insert("Charlie", 90);

println!("{:?}", scores.get("Alice"));     // Some(100)
println!("{}", scores.get("Dave").unwrap_or(&0)); // 0

for (name, score) in &scores {
    println!("{name}: {score}");
}

No Python equivalent: HashMap<K, V> requires K: Eq + Hash — checked when the map is built, not when you first use a bad key. Python only discovers an unhashable key at the scores[key] = ... call, as a runtime TypeError.

Same homogeneity rule as Vec — one K and one V for the whole map. Python's dict mixes key types and value types freely: {1: "a", "b": 2} is a completely valid dict.

Tuples

Python
point = (3, 4)
x, y = point          # destructuring
print(point[0])
Rust
let point = (3, 4);
let (x, y) = point;           // destructuring
println!("{}", point.0);      // index access with .0, .1, ...

let mixed = (3, "four", 5.0);  // (i32, &str, f64) — three different types, one tuple
The one Rust collection that mixes types — but not the way Python's does. (3, "four", 5.0) is the type (i32, &str, f64): each position's type is fixed at compile time, not just "whatever happens to be there." A Python tuple has no such constraint — you could build (3, "four", 5.0) and ("x", 1) with the same syntax and never see a type at all; Rust's tuple types are as strict as any other type, just structured as fixed, ordered slots instead of one repeated T.

8 · Strings and Their Methods

Strings are where Python developers hit the most friction. Python has one string type, indexes it by character, and lets you forget that text is bytes. Rust has two types, refuses to index by character, and never lets you forget.

3. Data Types introduced &str vs String. This section is the working reference.

The Two Types, Restated

&str String
Owns its data no — a borrowed view yes — heap-allocated
Can grow no yes
Size pointer + length (16 bytes) pointer + length + capacity (24 bytes)
Comes from literals, slicing, borrowing a String String::from, .to_string(), format!
Python analogue a read-only view of a str a str you built
Python
literal = "hello"                # a constant baked into the code object
owned = "".join(["hel", "lo"])   # built on the heap at runtime — same `str` type
borrowed = owned                 # another name for it; there is no view type
back = literal                   # nothing to copy — str is immutable, sharing is free
Rust
let literal: &str = "hello";               // baked into the binary
let owned: String = String::from("hello"); // on the heap
let borrowed: &str = &owned;               // view into the String
let back: String = literal.to_string();    // copy into the heap
The rule: take &str in function parameters, return String when you built something new.

Creating and Converting

Python
s = "hello"
s = str(42)
s = f"{a} {b}"
s = "".join(parts)
s = "ab" * 3
Rust
let s = "hello";                      // &str
let s = 42.to_string();               // String — via Display
let s = format!("{a} {b}");           // String — the f-string equivalent
let s = parts.concat();               // String — join with nothing
let s = parts.join(", ");             // String — join with a separator
let s = "ab".repeat(3);               // "ababab"

let s = String::new();                // empty, no allocation yet
let s = String::with_capacity(1024);  // pre-allocate — no Python equivalent

Four ways to get a String from a &str, all equivalent in effect:

Python
s = "x"
a = str(s)           # already a str — str() hands the same object back
b = s[:]             # full slice — the same object again, not a copy
c = "".join([s])     # single-element join — CPython returns it unchanged
d = s                # one str type, so "make it owned" isn't a thing to say
Rust
let a = "x".to_string();     // via Display — most common
let b = "x".to_owned();      // via ToOwned — signals "I need ownership"
let c = String::from("x");   // explicit constructor
let d: String = "x".into();  // inferred from the target type

Method Map — Python str to Rust

Python Rust Notes
len(s) s.len() bytes, not characters
len(s) (characters) s.chars().count() O(n), walks the string
s.upper() s.to_uppercase() returns String
s.lower() s.to_lowercase() returns String
s.strip() s.trim()
s.lstrip() / s.rstrip() s.trim_start() / s.trim_end()
s.strip("xy") s.trim_matches(|c| c == 'x' || c == 'y')
s.removeprefix(p) s.strip_prefix(p) returns Option<&str>
s.removesuffix(p) s.strip_suffix(p) returns Option<&str>
s.removeprefix(p).removesuffix(q) s.strip_circumfix(p, q) New in 1.98 — strips both in one call, Option<&str>
s.split(",") s.split(',') lazy iterator, not a list
s.split() s.split_whitespace()
s.split(",", 1) s.splitn(2, ',') note: n is total parts, not splits
s.rsplit(",") s.rsplit(',')
s.splitlines() s.lines() handles \n and \r\n
s.partition(sep) s.split_once(sep) Option<(&str, &str)>
s.rpartition(sep) s.rsplit_once(sep)
",".join(items) items.join(",") receiver and argument swap
s.replace(a, b) s.replace(a, b)
s.replace(a, b, 1) s.replacen(a, b, 1)
s.startswith(p) s.starts_with(p)
s.endswith(p) s.ends_with(p)
p in s s.contains(p)
s.find(p) s.find(p) Option<usize>byte offset
s.index(p) s.find(p).unwrap()
s.rfind(p) s.rfind(p)
s.count(p) s.matches(p).count()
s.ljust(10) format!("{s:<10}")
s.rjust(10) format!("{s:>10}")
s.center(10) format!("{s:^10}")
s.zfill(5) format!("{s:0>5}")
s * 3 s.repeat(3)
s[::-1] s.chars().rev().collect::<String>()
s.isdigit() s.chars().all(|c| c.is_ascii_digit())
s.isalpha() s.chars().all(char::is_alphabetic)
s.isspace() s.chars().all(char::is_whitespace)
s.encode() s.as_bytes() / s.into_bytes() borrow / consume
b.decode() String::from_utf8(v)? fails on invalid UTF-8
b.decode(errors="replace") String::from_utf8_lossy(&v) never fails
b.decode("utf-16-le") String::from_utf16le(&b) New in 1.98Result<String, _>; _lossy variant never fails
b.decode("utf-16-be") String::from_utf16be(&b) New in 1.98Result<String, _>; _lossy variant never fails
ord(c) c as u32
chr(n) char::from_u32(n) Option<char>
int(s) s.parse::<i32>()? returns Result
str(n) n.to_string()

Building Strings

Python
parts = []
for i in range(1000):
    parts.append(str(i))
result = "".join(parts)      # the idiomatic fast path

s = "a"
s += "b"                     # allocates a new string each time
Rust
// push_str / push — mutate in place
let mut s = String::new();
s.push_str("hello");
s.push(' ');                     // a single char
s.push_str("world");

// += works on String (takes &str on the right)
let mut s = String::from("a");
s += "b";

// + consumes the left operand — note the & on the right
let a = String::from("foo");
let b = String::from("bar");
let c = a + &b;                  // `a` is MOVED, no longer usable

// format! — clearest for anything non-trivial
let c = format!("{a}{b}");       // both still usable

// Pre-allocate when you know the size — no Python equivalent
let mut s = String::with_capacity(4096);
for i in 0..1000 {
    s.push_str(&i.to_string());  // zero reallocations
}

// collect from an iterator
let s: String = (0..5).map(|i| i.to_string()).collect();   // "01234"

// write! into a String — avoids intermediate allocations
use std::fmt::Write;
let mut s = String::new();
write!(s, "{}-{}", 1, 2).unwrap();

Iterating Over Text

Python's for c in s gives characters. Rust makes you say which unit you mean.

Python
for c in s: ...              # characters
for b in s.encode(): ...     # bytes
for i, c in enumerate(s): ...
for line in s.splitlines(): ...
Rust
for c in s.chars() { }               // char — Unicode scalar values
for b in s.bytes() { }               // u8 — raw UTF-8 bytes
for (i, c) in s.char_indices() { }   // (byte offset, char) — NOT 0,1,2,3
for line in s.lines() { }            // &str per line
for word in s.split_whitespace() { } // &str per word
char_indices() is not enumerate(). It yields byte offsets, so a string with multi-byte characters gives 0, 1, 3, 4 rather than 0, 1, 2, 3. If you want a sequential counter, use s.chars().enumerate().

Why You Cannot Index a String

Python
s = "hello"
c = s[0]         # 'h'
c = s[-1]        # 'o'
sub = s[1:3]     # 'el'
Rust
let s = "hello";
// let c = s[0];       // COMPILE ERROR — String/&str don't implement Index<usize>

This is deliberate. Rust strings are guaranteed valid UTF-8, where a character takes 1–4 bytes. So s[0] would have to mean one of three different things, and two of them are wrong:

Interpretation Cost Problem
The first byte O(1) may be half a character
The first char O(1) here, O(n) generally inconsistent with slicing
The first grapheme O(n) needs a Unicode table

Python 3 hides this by storing strings as fixed-width arrays internally, paying memory for O(1) indexing. Rust refuses the trade.

Python
s = "héllo"

len(s)                   # 5 — code points, not bytes and not graphemes
len(s.encode())          # 6 — bytes, only if you go and ask for them

s[0]                     # 'h' — O(1), paid for in memory at build time
s[1]                     # 'é' — O(1) too; the cost is hidden, not absent
s[-1]                    # 'o'

s[0:1]                   # 'h' — code-point indices, so never half a char
s[1:2]                   # 'é' — no panic; a slice can't split a code point
s.encode()[1:2]          # b'\xc3' — half of 'é', silently, no complaint
# s.encode()[1:2].decode()   # UnicodeDecodeError — one step too late
Rust
let s = "héllo";

s.len();                    // 6 — bytes, because é is 2 bytes
s.chars().count();          // 5 — characters

s.chars().next();           // Some('h')  — first char
s.chars().nth(1);           // Some('é')  — O(n), and that's visible
s.chars().last();           // Some('o')  — Python's s[-1]

&s[0..1];                   // "h" — fine, byte 1 is a boundary
// &s[1..2];                // PANICS at runtime: byte index 2 is inside 'é'
s.get(1..2);                // None — the non-panicking version

Graphemes are a third layer the standard library doesn't handle at all:

Python
len("é")            # 1 — precomposed é
len("é")           # 2 — e + combining accent, renders identically
len("👨‍👩‍👧")             # 5 — three people plus two zero-width joiners
# no grapheme iterator in the stdlib either: pip install regex, regex.findall(r"\X", s)
Rust
// "é" can be one char (U+00E9) or two (e + U+0301 combining accent).
// Emoji like 👨‍👩‍👧 are several chars joined by zero-width joiners.
// s.chars().count() on that emoji returns 5, not 1.
// For user-perceived characters, use the unicode-segmentation crate.

Python has exactly the same problem — len("👨‍👩‍👧") is 5 there too. Rust just refuses to let you pretend otherwise.

Slicing Safely

Python
s = "héllo wörld"

# Slices are by code point — always safe, never raise
head = s[0:2]                   # 'hé'
s[0:999]                        # 'héllo wörld' — out of range is clamped

# The dangerous layer is bytes, and it fails one step late
half = s.encode()[0:2]          # b'h\xc3' — the front half of 'é'
# half.decode()                 # UnicodeDecodeError, not at the slice

# Find a real index before slicing
idx = s.find(" ")               # 5 — a char index; Rust's find returns 6, a byte offset
if idx != -1:                   # -1 on failure, not None — easy to slice with by mistake
    first, rest = s[:idx], s[idx:]      # two fresh copies, not views

# Best: partition instead of index arithmetic
key, sep, value = "host=localhost".partition("=")
if sep:
    print(f"{key} -> {value}")

# split with maxsplit works too, but the unpack is the failure mode
key, value = "host=localhost".split("=", 1)   # ValueError on unpack if "=" is absent

# A slice remembers nothing about its parent — no substr_range equivalent
"host=localhost".index(key)     # 0, and only by searching the text over again
Rust
let s = "héllo wörld";

// Byte ranges — panics on a non-boundary
let head = &s[0..2];

// Non-panicking
if let Some(head) = s.get(0..2) { }

// Character-based, safely
let first3: String = s.chars().take(3).collect();
let skip2: String = s.chars().skip(2).collect();

// Find a real boundary before slicing
if let Some(idx) = s.find(' ') {
    let (first, rest) = s.split_at(idx);   // idx came from find, so it's valid
}

// Best: split_once instead of index arithmetic
if let Some((key, value)) = "host=localhost".split_once('=') {
    println!("{key} -> {value}");
}

// New in 1.98 — recover a derived slice's byte range within its parent
let s = "host=localhost";
let (key, _) = s.split_once('=').unwrap();
let range = s.substr_range(key);   // Some(0..4) — where `key` sits inside `s`

Byte offsets from find() are safe to slice with, because find only ever returns character boundaries. Offsets you compute yourself are not.

New in 1.98: substr_range (and its [T]::subslice_range sibling) — given a &str/&[T] you know is a view into a longer one, it hands back the byte range it occupies in the parent. Python slices are copies with no memory of where they came from, so this problem — and its solution — doesn't exist there.

Parsing

Python
n = int("42")
f = float("3.14")
try:
    n = int(user_input)
except ValueError:
    n = 0
Rust
let n: i32 = "42".parse()?;                     // turbofish alternative:
let n = "42".parse::<i32>()?;                   // parse returns Result
let f: f64 = "3.14".parse()?;

let n: i32 = user_input.parse().unwrap_or(0);   // with a default

match user_input.trim().parse::<i32>() {
    Ok(n) => println!("got {n}"),
    Err(e) => eprintln!("bad number: {e}"),
}

parse works for any type implementing FromStr — including your own:

Python
from dataclasses import dataclass

@dataclass
class Point:
    x: int
    y: int

    @classmethod
    def parse(cls, s: str) -> "Point":
        x, sep, y = s.partition(",")
        if not sep:
            raise ValueError("expected x,y")
        return cls(int(x), int(y))   # int() strips space and raises on junk

p = Point.parse("3, 4")              # Point(x=3, y=4)
# no FromStr to dispatch on, so the caller must name Point — nothing
# lets a bare "3, 4".parse() infer the target type from context
Rust
use std::str::FromStr;

struct Point { x: i32, y: i32 }

impl FromStr for Point {
    type Err = String;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let (x, y) = s.split_once(',').ok_or("expected x,y")?;
        Ok(Point {
            x: x.trim().parse().map_err(|_| "bad x")?,
            y: y.trim().parse().map_err(|_| "bad y")?,
        })
    }
}

let p: Point = "3, 4".parse()?;

Choosing Parameter and Return Types

The decision Python never makes. Getting this right is most of writing idiomatic Rust.

You need Use Why
Read text, don't keep it fn f(s: &str) accepts literals, Strings, and slices — no copy
Keep or modify the text fn f(s: String) caller decides to give up ownership
Accept anything string-ish and store it fn f(s: impl Into<String>) caller can pass either, one conversion
Generic over borrowed forms fn f(s: impl AsRef<str>) accepts &str, String, Cow<str>not Path/OsStr, which aren't guaranteed UTF-8 (use impl AsRef<Path> for those)
Return text, maybe unchanged Cow<'_, str> allocate only when you actually modify
Python
def word_count(text: str) -> int:    # only one string type to pick from
    return len(text.split())

owned = " ".join(["a", "b", "c"])
word_count("a b c")     # 3 — a literal
word_count(owned)       # 3 — a built-up str; same call, nothing coerced
# what the signature can't say: "I only read this, I won't keep it"
Rust
// The default — 90% of functions want this
fn word_count(text: &str) -> usize {
    text.split_whitespace().count()
}

word_count("a b c");           // &str — works
word_count(&owned_string);      // String — deref coercion, works

Cow — clone on write. Python always allocates a new string; Rust can skip it. (The '_ below is a lifetime placeholder — don't worry about it yet; 16. Lifetimes explains what it means.)

Python

No Python equivalent. Python strings are always owned. There is no borrowed-or-owned union to express, so there is no Cow and no decision to make — you pay for the copy every time.

Rust
use std::borrow::Cow;

fn sanitize(input: &str) -> Cow<'_, str> {
    if input.contains(' ') {
        Cow::Owned(input.replace(' ', "_"))   // allocate — we changed something
    } else {
        Cow::Borrowed(input)                  // no allocation at all
    }
}

Gotchas Summary

Gotcha Detail
.len() is bytes "héllo".len() == 6, not 5
s[0] doesn't compile use .chars().next()
&s[a..b] can panic only slice at boundaries, or use .get()
find() returns a byte offset never mix it with chars().nth()
char_indices() isn't enumerate() it yields byte offsets
join is reversed items.join(","), not ",".join(items)
a + &b moves a use format! if you need both afterwards
to_uppercase() can change length 'ß' becomes "SS"
split returns a lazy iterator add .collect::<Vec<_>>() for a list
splitn(2, x) means 2 parts Python's split(x, 1) means 1 split
Comparing types just works String == &str compiles fine
chars() isn't graphemes emoji and accents need unicode-segmentation

9 · Structs (Python Classes — Data Part)

Python
from dataclasses import dataclass

@dataclass
class User:
    id: int
    name: str
    email: str

user = User(id=1, name="Alice", email="[email protected]")
print(user.name)
Rust
struct User {
    id: u64,
    name: String,
    email: String,
}

let user = User {
    id: 1,
    name: String::from("Alice"),
    email: String::from("[email protected]"),
};
println!("{}", user.name);

Add methods with impl:

Python
@dataclass
class User:
    id: int
    name: str
    email: str

    def greet(self) -> str:
        return f"Hi, I'm {self.name}"

    @classmethod
    def guest(cls) -> "User":
        return cls(id=0, name="Guest", email="")
Rust
struct User {
    id: u64,
    name: String,
    email: String,
}

impl User {
    // associated function (like @classmethod / static method) — no self
    fn guest() -> User {
        User { id: 0, name: String::from("Guest"), email: String::new() }
    }

    // method — takes &self (read-only borrow, like Python's self)
    fn greet(&self) -> String {
        format!("Hi, I'm {}", self.name)
    }

    // mutable method — takes &mut self
    fn rename(&mut self, new_name: &str) {
        self.name = new_name.to_string();
    }
}

let mut user = User::guest();
user.rename("Alice");
println!("{}", user.greet());

self comparison:

Python Rust Meaning
self self Takes ownership (consumes the struct)
self (read) &self Borrow immutably — most common
self (write) &mut self Borrow mutably
cls / @staticmethod no self in impl Associated function

Struct Features Python Doesn't Have

Drop — deterministic destructors (RAII)

Python's __del__ fires whenever the GC gets around to it — you can't rely on when. That's why Python needs with blocks and contextlib. In Rust, cleanup runs the instant the value leaves scope, guaranteed, even on early return or panic. No with needed.

Python
# Python — cleanup requires an explicit context manager
class Connection:
    def __enter__(self): return self
    def __exit__(self, *exc): self.close()
    def close(self): print("closed")

with Connection() as c:      # forget `with` and it may never close
    ...
Rust
struct Connection { name: String }

impl Drop for Connection {
    fn drop(&mut self) {
        println!("closing {}", self.name);   // runs automatically
    }
}

fn main() {
    let _c = Connection { name: "db".into() };
    // ... use it ...
}   // <- "closing db" prints HERE, always. No with-block, no try/finally.

This is how MutexGuard, File, and Vec all free themselves — one mechanism, no protocol to remember.

Struct update syntax and Default

Python
from dataclasses import dataclass, replace

@dataclass
class Config:
    host: str = "localhost"
    port: int = 8080
    debug: bool = False

base = Config()
prod = replace(base, host="prod.example.com")   # needs dataclasses.replace
Rust
#[derive(Default, Clone)]
struct Config {
    host: String,
    port: u16,
    debug: bool,
}

let prod = Config {
    host: "prod.example.com".into(),
    ..Default::default()          // fill every remaining field from Default
};

let staging = Config { debug: true, ..prod.clone() };   // or from another value

Compiler-enforced field privacy

Python's _private is a naming convention — nothing stops access. Rust's default is private and the compiler refuses.

Python
class Account:
    def __init__(self, id: int, balance: int) -> None:
        self.id = id
        self._balance = balance    # underscore is a request, not a rule

    @property
    def balance(self) -> int:      # read-only accessor
        return self._balance

a = Account(1, 100)
a._balance          # 100 — the convention stopped nobody
a._balance = -999   # and nothing stops this either
# __balance only mangles the name to _Account__balance — still reachable
Rust
mod account {
    pub struct Account {
        pub id: u64,
        balance: i64,          // no `pub` — invisible outside this module
    }

    impl Account {
        pub fn balance(&self) -> i64 { self.balance }   // read-only accessor
    }
}

let a = account::Account { id: 1, balance: 100 };  // compile error: `balance` is private

No inheritance — by design

There is no class B(A). Shared behaviour comes from traits with default methods (12. Traits (Python Protocols and ABCs)) and composition. This removes the fragile-base-class and MRO problems Python's super() inherits.

10 · Enums (Python Enum and tagged unions)

Python's Enum holds simple variants. Rust enums hold data too, making them far more powerful.

Python
from enum import Enum

class Direction(Enum):
    NORTH = "north"
    SOUTH = "south"

class Shape(Enum):
    CIRCLE = "circle"
    RECTANGLE = "rectangle"

# Python can't cleanly attach different data per variant
Rust
enum Direction {
    North,
    South,
    East,
    West,
}

// Each variant can hold different data
enum Shape {
    Circle { radius: f64 },
    Rectangle { width: f64, height: f64 },
    Triangle(f64, f64, f64),     // unnamed fields (tuple variant)
}

fn area(shape: &Shape) -> f64 {
    match shape {
        Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
        Shape::Rectangle { width, height } => width * height,
        Shape::Triangle(a, b, c) => {
            let s = (a + b + c) / 2.0;
            (s * (s - a) * (s - b) * (s - c)).sqrt()
        }
    }
}

Enum Features Python Doesn't Have

No Python equivalent: a Rust enum is a true tagged union — one type where each variant carries its own, differently-shaped, statically-typed payload, and match on it is checked for exhaustiveness. Python's Enum members are fixed scalar values; there's no single type that lets different members hold different data shapes with the compiler verifying every case is handled.

The nearest Python approximation loses both properties:

Python
from dataclasses import dataclass
from typing import Union

@dataclass
class Circle:
    radius: float

@dataclass
class Rectangle:
    width: float
    height: float

Shape = Union[Circle, Rectangle]   # not one type — just a type-checker alias

def area(shape: Shape) -> float:
    if isinstance(shape, Circle):
        return 3.14159 * shape.radius ** 2
    # forget the Rectangle branch — mypy may warn, but nothing stops it running,
    # and there's no runtime error until something downstream breaks
Rust
// Add a variant to Shape and this stops compiling until every match handles it
fn area(shape: &Shape) -> f64 {
    match shape {
        Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
        Shape::Rectangle { width, height } => width * height,
        // omit Triangle here → compile error, not a maybe-caught type-checker warning
    }
}
Python Rust
One type, many payload shapes no — Union[...] is a type-checker fiction yes — enum is one real type
Missing-case detection mypy only, optional, not enforced at runtime compiler-enforced, always
Runtime cost of the check none — because there is none none — resolved at compile time

11 · Error Handling

This is the biggest conceptual shift. Python uses exceptions; Rust uses return values.

Python
def read_file(path: str) -> str:
    try:
        with open(path) as f:
            return f.read()
    except FileNotFoundError:
        return ""
    except PermissionError as e:
        raise RuntimeError(f"Cannot read {path}") from e

# Caller
try:
    content = read_file("data.txt")
except RuntimeError as e:
    print(f"Error: {e}")
Rust
use std::fs;

fn read_file(path: &str) -> Result<String, std::io::Error> {
    let content = fs::read_to_string(path)?;  // ? = propagate error up
    Ok(content)
}

// Caller
match read_file("data.txt") {
    Ok(content) => println!("{content}"),
    Err(e) => eprintln!("Error: {e}"),
}

? operator — Rust's equivalent of raise in a try block:

Python
# the verbose form — catch only to hand the error straight back
def read_file(path: str) -> str:
    try:
        with open(path) as f:
            return f.read()
    except OSError:
        raise                        # re-raise: propagate the error

# the form people actually write — propagation is the default
def read_file(path: str) -> str:
    with open(path) as f:
        return f.read()              # an OSError leaves on its own; no `?`, no Ok()

# nothing in the signature mentions OSError, and no caller is forced to look
Rust
// Without ? — verbose
fn read_file(path: &str) -> Result<String, std::io::Error> {
    let content = match fs::read_to_string(path) {
        Ok(c) => c,
        Err(e) => return Err(e),   // propagate the error
    };
    Ok(content)
}

// With ? — concise (equivalent to above)
fn read_file(path: &str) -> Result<String, std::io::Error> {
    let content = fs::read_to_string(path)?;
    Ok(content)
}
No Python equivalent: ? also converts the error type via From when the function's error type differs from the one it just propagated — silently, resolved at compile time, no code at the call site. Python's closest move, raise ... from e, has to be written out explicitly at every single call site; there's no mechanism that converts automatically.

anyhow — ergonomic errors for applications (equivalent to Python's general exceptions):

Python
# Python — just raise with a message
def process(path: str) -> str:
    content = read_file(path)
    if not content:
        raise ValueError("empty file")
    return content.upper()
Rust
use anyhow::{Result, bail, Context};

fn process(path: &str) -> Result<String> {
    let content = fs::read_to_string(path)
        .with_context(|| format!("failed to read {path}"))?;

    if content.is_empty() {
        bail!("empty file");           // like raise ValueError
    }

    Ok(content.to_uppercase())
}

Option<T> — replacing None checks

Python
def find_user(id: int) -> dict | None:
    users = {1: {"name": "Alice"}}
    return users.get(id)

user = find_user(1)
if user is not None:
    print(user["name"])

# Or with walrus
if user := find_user(2):
    print(user["name"])
else:
    print("not found")
Rust
fn find_user(id: u64) -> Option<String> {
    let users = std::collections::HashMap::from([(1u64, "Alice")]);
    users.get(&id).map(|s| s.to_string())
}

match find_user(1) {
    Some(name) => println!("{name}"),
    None => println!("not found"),
}

// Shorter form
if let Some(name) = find_user(2) {
    println!("{name}");
}

// With default
let name = find_user(3).unwrap_or_else(|| String::from("Anonymous"));

Option / Result methods — Python vs Rust:

Python Rust Description
x if x is not None else default .unwrap_or(default) Provide a default
func(x) if x else None .map(|x| func(x)) Transform if present
x or raise .unwrap() Get value or panic
x or raise ValueError("msg") .expect("msg") Panic with message
try/except propagation ? operator Propagate error up

Panics — Rust's Other Kind of Failure

Everything above is about expected failure: a file might be missing, a user might not exist. Rust has a second, completely separate channel for bugs — states that should be impossible. That's panic!.

Python conflates the two. raise is how you signal both "this user doesn't exist" and "my invariant is broken." Rust splits them, and the split is visible in the type signature.

Expected failure Bug / impossible state
Rust Result<T, E> / Option<T> panic!
Python raise ValueError raise AssertionError (convention only)
Visible in the signature? yes no
Should the caller handle it? yes, must no
Effect returns a value unwinds the stack, kills the thread
Recoverable? yes not meaningfully

panic! really is raise — mechanically

The machinery is nearly identical, and it helps to say so plainly:

Behaviour Python raise Rust panic!
Aborts normal control flow
Unwinds the stack ✅ (by default)
Runs cleanup on the way out finally, __exit__ Drop impls
Carries a message
Prints a traceback ✅ with RUST_BACKTRACE=1
Appears in the signature ❌ no checked exceptions ❌ not in the type
Can be caught except catch_unwind (see below)

So Drop-during-unwind is Python's finally, and neither mechanism is visible in the signature. On that axis Result is the outlier, not panic!.

The real difference is role, not machinery. raise is Python's primary error channel; panic! is Rust's secondary one. The mapping is one-to-two, and which one you want depends entirely on intent:

Python Rust Why
raise ValueError("no such user") return Err(...) expected — the caller must cope
raise AssertionError("balance < 0") panic!(...) a bug — nobody should cope
The one-line version: panic! is an exception you have decided in advance never to catch. Its closest Python sibling is AssertionError, not ValueError.
Python
# Python — one mechanism, two meanings, indistinguishable from the signature
def withdraw(balance: int, amount: int) -> int:
    if amount > balance:
        raise ValueError("insufficient funds")   # expected — caller should handle
    if balance < 0:
        raise AssertionError("negative balance") # a bug — nobody should catch this
    return balance - amount
Rust
fn withdraw(balance: i64, amount: i64) -> Result<i64, String> {
    assert!(balance >= 0, "negative balance: {balance}");  // a BUG — panics

    if amount > balance {
        return Err("insufficient funds".into());          // expected — returned
    }
    Ok(balance - amount)
}
// The signature tells you the only error you must handle is the String.
// The panic isn't in the type — because it's not supposed to happen.

The panic family

All of these panic. They differ only in what they tell the reader.

Macro Means Python equivalent
panic!("msg") unrecoverable bug, here's why unhandled raise
unreachable!() this code is logically impossible raise AssertionError("unreachable")
todo!() not written yet, will be raise NotImplementedError / ...
unimplemented!() intentionally never supported raise NotImplementedError
assert!(cond) invariant check, always compiled (survives --release) no direct equivalent — if not cond: raise AssertionError(...)
assert_eq!(a, b) equality invariant, always compiled if a != b: raise AssertionError(...)
debug_assert!(cond) invariant check, debug builds only assert cond — this is the real match, stripped by python -O
.unwrap() "trust me, this is Some/Ok" x[0] and hope
.expect("msg") same, with a reason attached assert x is not None, "msg"

unreachable!() — proving a branch can't happen

match is exhaustive, so sometimes you must write an arm you know is dead. unreachable!() documents that for both the compiler and the next reader.

Python
def parse_digit(c: str) -> int:
    match c:
        case d if d.isdigit():
            return int(d)
        case _:
            raise AssertionError(f"parse_digit called with non-digit: {c}")

# also common after a check the type system can't see:
match "a=b".split("=", 1):
    case [key, value]:
        print(key, value)                 # a b
    case _:
        raise AssertionError("split(maxsplit=1) always yields 1 or 2 parts")
# `assert False` is the shorter spelling — and disappears entirely under python -O
Rust
fn parse_digit(c: char) -> u32 {
    match c {
        '0'..='9' => c.to_digit(10).unwrap(),
        _ => unreachable!("parse_digit called with non-digit: {c}"),
    }
}

// Also common after a check the type system can't see:
let parts: Vec<&str> = "a=b".splitn(2, '=').collect();
let (key, value) = match parts.as_slice() {
    [k, v] => (k, v),
    _ => unreachable!("splitn(2) always yields 1 or 2 parts"),
};
Prefer restructuring so the impossible case can't be expressed at all. unreachable!() is an admission that the types are looser than your logic.

todo!() — compiling incomplete code

This is more useful than it looks, and it works because todo!() has type ! (the never type from 3. Data Types), so it satisfies any return type.

Python
# Python — a stub body needs no type to line up
def calculate_tax(order: Order) -> Decimal:
    ...                              # or: raise NotImplementedError
Rust
fn calculate_tax(order: &Order) -> Decimal {
    todo!("waiting on the rates table")   // type-checks as Decimal. Or as anything.
}

You can sketch a whole module's signatures with todo!() bodies, let the compiler verify that all the shapes fit together, then fill in logic afterwards. Python can't offer this, because it never checked the shapes to begin with.

About all those .unwrap() calls

.unwrap() appears throughout this document — that's brevity in examples, not a recommendation. It means "panic if this isn't Some/Ok."

Python
n = int("42")                                        # ValueError on bad input — like .unwrap()
n = int("42")                                        # no reason string to attach: the traceback is all you get
n = int("42") if "42".lstrip("-").isdigit() else 0   # no exception — default, but you guard instead of recover
n = int("42")                                        # no exception — propagate; that's just python's default
Rust
let n: i32 = "42".parse().unwrap();                     // panics on bad input
let n: i32 = "42".parse().expect("literal is valid");   // panics with a reason
let n: i32 = "42".parse().unwrap_or(0);                 // no panic — default
let n: i32 = "42".parse()?;                             // no panic — propagate
Where .unwrap() acceptable?
Tests ✅ yes — a panic is a test failure
Prototypes and scripts ✅ fine
main() in a small binary acceptable, but ? is better
Library code ❌ never — you're panicking in someone else's process
Anything parsing external input ❌ never — that's expected failure

Prefer .expect("why this cannot fail") over a bare .unwrap(): identical behaviour, but the message becomes a comment that shows up in the crash.

Unwind vs abort

TOML
# Cargo.toml — panics normally unwind the stack, running every Drop.
# Switch to abort for smaller binaries and no unwinding machinery.
[profile.release]
panic = "abort"

std::panic::catch_unwind is the nearest thing to try/except, and it is deliberately worse at the job:

Python
try:
    raise RuntimeError("boom")
except RuntimeError as e:            # a typed exception, not a Box<dyn Any> to downcast
    print(f"caught: {e}")            # .args, .__traceback__, .__cause__ all still attached
    # raise                          # re-raising here keeps the original traceback

# the bare form is the closer analogue to catch_unwind
try:
    int("boom")
except:                              # == except BaseException — swallows SystemExit too
    pass
finally:
    print("cleanup runs either way") # rust's equivalent is Drop, run as the stack unwinds
Rust
use std::panic;

let result = panic::catch_unwind(|| {
    panic!("boom");
});
match result {
    Ok(v) => println!("fine: {v:?}"),
    Err(payload) => {
        // payload is Box<dyn Any> — not a typed exception hierarchy
        if let Some(s) = payload.downcast_ref::<&str>() {
            eprintln!("caught: {s}");
        }
    }
}
Why it isn't except Consequence
Requires the closure to be UnwindSafe many closures simply won't compile
Does nothing under panic = "abort" behaviour depends on the build profile
Payload is Box<dyn Any> no exception types to match on — you downcast and guess
A panic inside a Drop while unwinding aborts the process immediately, no catching
Panic granularity is the thread main thread panicking exits with code 101
A caught panic may leave broken invariants continuing afterwards isn't necessarily sound

Its real jobs are stopping unwinding at an FFI boundary (unwinding into C is undefined behaviour) and supervising worker threads — JoinHandle::join already returns Err(payload) if that thread panicked, which is the sanctioned way to survive one. Using catch_unwind for ordinary control flow is fighting the language.

Concept Python Rust
Recoverable error raise + except Result + ?
Unrecoverable bug raise + nobody catches panic!
Distinguishable in the signature no yes
Assertions in release builds stripped by -O assert! kept, debug_assert! stripped
"Can't happen" marker none unreachable!()
Stub that type-checks ... todo!()
Catch everything except Exception catch_unwind (discouraged)

12 · Traits (Python Protocols and ABCs)

Python gets conformance by duck typing or Protocol; Rust makes it explicit with traits.

Python
from typing import Protocol

class Greetable(Protocol):
    def greet(self) -> str: ...

class User:
    def greet(self) -> str:
        return "Hi from User"

class Bot:
    def greet(self) -> str:
        return "Beep boop"

def print_greeting(thing: Greetable) -> None:
    print(thing.greet())

print_greeting(User())
print_greeting(Bot())
Rust
trait Greetable {
    fn greet(&self) -> String;

    // default implementation
    fn loud_greet(&self) -> String {
        self.greet().to_uppercase()
    }
}

struct User { name: String }
struct Bot;

impl Greetable for User {
    fn greet(&self) -> String {
        format!("Hi from {}", self.name)
    }
}

impl Greetable for Bot {
    fn greet(&self) -> String {
        String::from("Beep boop")
    }
}

fn print_greeting(thing: &impl Greetable) {
    println!("{}", thing.greet());
}

print_greeting(&User { name: String::from("Alice") });
print_greeting(&Bot);

Common standard traits (like Python dunders):

Python dunder Rust trait Purpose
__str__ Display println!("{}")
__repr__ Debug println!("{:?}")
__eq__ PartialEq / Eq == operator
__lt__ etc. PartialOrd / Ord < > operators
__hash__ Hash Use in HashMap/HashSet
__clone__ Clone .clone()
__copy__ Copy Auto-copy on assign
__add__ Add + operator
__iter__ Iterator for loops

Derive them automatically with #[derive(...)]:

Python
@dataclass
class Point:        # __eq__, __repr__ etc. auto-generated
    x: float
    y: float
Rust
#[derive(Debug, Clone, PartialEq)]
struct Point {
    x: f64,
    y: f64,
}

let p = Point { x: 1.0, y: 2.0 };
println!("{p:?}");              // Debug: Point { x: 1.0, y: 2.0 }
let p2 = p.clone();
assert_eq!(p, p2);              // PartialEq

Trait Features Python Doesn't Have

Traits are where Rust diverges most from Python. Python has exactly one dispatch strategy — look the method up in a dict at runtime. Rust has two, and you choose.

1. Static vs dynamic dispatch — a choice Python doesn't offer

Python
# Python: always dynamic. Every call is a runtime dict lookup on the instance.
def print_greeting(thing: Greetable) -> None:
    print(thing.greet())
Rust
// STATIC dispatch — compiler generates one copy per concrete type.
// Zero runtime cost, call is inlinable, binary is larger.
fn print_greeting(thing: &impl Greetable) {
    println!("{}", thing.greet());
}

// DYNAMIC dispatch — one copy, method looked up through a vtable pointer.
// Small runtime cost, smaller binary, allows heterogeneous collections.
fn print_greeting_dyn(thing: &dyn Greetable) {
    println!("{}", thing.greet());
}

// dyn is what you need for a mixed collection — the type isn't known statically
let things: Vec<Box<dyn Greetable>> = vec![
    Box::new(User { name: "Alice".into() }),
    Box::new(Bot),
];
for t in &things {
    println!("{}", t.greet());
}
Vec<Box<dyn Trait>> is the Rust spelling of a plain Python list of mixed objects. It looks heavy because Rust is making the cost visible — Python pays it on every call and never tells you.

2. Associated types

A trait can declare a type that each implementor fills in. Python's Protocol cannot express this — the closest is a generic Protocol[T], which is erased anyway.

Python
from typing import NamedTuple, Protocol

class User(NamedTuple):
    name: str

class Repository[ItemT](Protocol):        # the item type must be a parameter of the whole protocol
    def get(self, id: int) -> ItemT: ...  # and there is no slot for an error type at all

class UserRepo:
    def get(self, id: int) -> User:
        return User(f"user{id}")

repo: Repository[User] = UserRepo()       # structural — UserRepo never names Repository
print(repo.get(1).name)                   # user1
# Repository[User] is erased at runtime: nothing checks it, nothing dispatches on it
Rust
trait Repository {
    type Item;                                  // implementor decides
    type Error;

    fn get(&self, id: u64) -> Result<Self::Item, Self::Error>;
}

struct UserRepo;

impl Repository for UserRepo {
    type Item = User;
    type Error = std::io::Error;

    fn get(&self, id: u64) -> Result<User, std::io::Error> {
        Ok(User { name: format!("user{id}") })
    }
}

This is exactly how Iterator works: type Item is why .map() knows what it's mapping over.

3. Blanket impls — add a method to every type at once

Python

No Python equivalent. You cannot add a method to every type at once. Monkeypatching reaches one class at a time, and cannot touch built-ins like int at all.

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

// Implement Describe for EVERY type that already implements Display
impl<T: std::fmt::Display> Describe for T {
    fn describe(&self) -> String {
        format!("<<{self}>>")
    }
}

println!("{}", 42.describe());        // "<<42>>"
println!("{}", "hi".describe());      // "<<hi>>"

Python has no equivalent — you'd have to monkeypatch each builtin, and you can't patch int at all.

4. The orphan rule — why you can't monkeypatch

Rust forbids this by coherence: you may implement a trait for a type only if you own the trait or the type. This guarantees exactly one implementation exists program-wide, so behaviour can't change based on which crates happen to be linked.

Python
# Python: monkeypatching is allowed, and it's global and invisible
import datetime
datetime.date.today = lambda: "lol"    # every library in the process is affected
Rust
// impl Display for Vec<u8> { }   // ERROR: both Display and Vec are foreign

// The sanctioned workaround: your own trait (an "extension trait")
trait Hex {
    fn to_hex(&self) -> String;
}

impl Hex for Vec<u8> {                 // legal — Hex is mine
    fn to_hex(&self) -> String {
        self.iter().map(|b| format!("{b:02x}")).collect()
    }
}

// Or the newtype pattern to make the type yours
struct Bytes(Vec<u8>);
impl std::fmt::Display for Bytes { /* now legal */ }

5. Marker traits — Send, Sync, Copy, Sized

Traits with no methods that carry compiler-checked guarantees. Send/Sync are how Rust makes data races a compile error — see 17. Concurrency.

Rust Python Gap
impl Trait (static dispatch) none Python is always dynamic
dyn Trait (vtable) normal method call Cost is explicit in Rust
type Item (associated type) none Protocol can't express it
Blanket impl<T: X> Y for T none Can't extend builtins
Orphan rule monkeypatching allowed Coherence guarantee
Send / Sync none Thread safety in the type system
Default trait methods ABC default methods ✅ Comparable

13 · Closures & Iterators

Python
numbers = [1, 2, 3, 4, 5]

# map / filter
doubled = list(map(lambda x: x * 2, numbers))
evens = list(filter(lambda x: x % 2 == 0, numbers))

# list comprehension (idiomatic Python)
doubled = [x * 2 for x in numbers]
evens = [x for x in numbers if x % 2 == 0]

# reduce
from functools import reduce
total = reduce(lambda acc, x: acc + x, numbers, 0)
total = sum(numbers)
Rust
let numbers = vec![1, 2, 3, 4, 5];

// Closures use |args| body syntax
let doubled: Vec<i32> = numbers.iter().map(|x| x * 2).collect();
let evens: Vec<i32> = numbers.iter().filter(|&&x| x % 2 == 0).cloned().collect();

// Chaining (equivalent to nested comprehensions)
let result: Vec<i32> = numbers.iter()
    .filter(|&&x| x % 2 == 0)
    .map(|&x| x * 10)
    .collect();

// fold = reduce
let total: i32 = numbers.iter().fold(0, |acc, x| acc + x);
let total: i32 = numbers.iter().sum();         // shorthand

Iterator methods — Python vs Rust:

Python Rust Description
map(fn, it) .map(|x| ...) Transform each element
filter(fn, it) .filter(|x| ...) Keep matching elements
sum(it) .sum() Sum all elements
any(fn, it) .any(|x| ...) True if any match
all(fn, it) .all(|x| ...) True if all match
list(it) .collect::<Vec<_>>() Materialise into collection
len(list) .count() Count elements
enumerate(it) .enumerate() Index + value pairs
zip(a, b) .zip(b) Pair two iterators
it[::2] .step_by(2) Every nth element
it[:5] .take(5) First n elements
it[5:] .skip(5) Skip first n elements
chain(a, b) .chain(b) Concatenate iterators
sorted(it) .collect() then .sort() Sort
max(it) .max() Maximum
min(it) .min() Minimum

Closure Features Python Doesn't Have

Fn, FnMut and FnOnce — how a closure captures is part of its type

Every Python closure captures by reference to a cell, always. Rust has three closure traits, inferred from what the body actually does, and a function can demand the one it needs.

Trait Captures by Can be called Python analogue
Fn immutable borrow many times closure that only reads
FnMut mutable borrow many times, mutates state closure using nonlocal
FnOnce by value (consumes) exactly once none
Python
data = [1, 2, 3]

# Fn — only reads captured state
printer = lambda: print(data)
printer()
printer()                      # fine, called twice

# FnMut — mutates captured state
count = 0
def bump():
    global count               # `nonlocal` in a nested scope; at module level, `global`
    count += 1
bump()
bump()

# FnOnce — no python equivalent
def consume():
    owned = data               # just another name for the same list, not ownership
    print(len(owned))
consume()
consume()                      # runs again happily — nothing was moved out of `data`
Rust
let data = vec![1, 2, 3];

// Fn — only reads captured state
let printer = || println!("{data:?}");
printer();
printer();                     // fine, called twice

// FnMut — mutates captured state
let mut count = 0;
let mut bump = || count += 1;   // note: closure itself must be `mut`
bump();
bump();

// FnOnce — consumes what it captures
let consume = move || {
    let owned = data;           // takes ownership of data
    println!("{}", owned.len());
};
consume();
// consume();                   // compile error: value moved in the first call

Taking a closure as a parameter means naming which one you accept:

Python
def apply_twice(f, x):          # Python doesn't care what f captures
    return f(f(x))
Rust
fn apply_twice<F: Fn(i32) -> i32>(f: F, x: i32) -> i32 {
    f(f(x))                     // needs Fn — FnOnce wouldn't allow two calls
}

fn run_once<F: FnOnce()>(f: F) {
    f();                        // accepts a closure that consumes its captures
}

move closures — forcing ownership transfer

Python
# Python: the closure sees the variable, not a copy. Classic late-binding bug:
fns = [lambda: i for i in range(3)]
print([f() for f in fns])      # [2, 2, 2] — all share the same `i`
Rust
// Rust: `move` transfers ownership into the closure — each gets its own value
let fns: Vec<Box<dyn Fn() -> i32>> = (0..3)
    .map(|i| Box::new(move || i) as Box<dyn Fn() -> i32>)
    .collect();

let vals: Vec<i32> = fns.iter().map(|f| f()).collect();
// [0, 1, 2] — the late-binding bug is impossible

move is mandatory when a closure outlives its scope — spawning a thread, returning a closure, or storing one in a struct.

Iterator chains compile away

Python generators are lazy, so laziness itself isn't the gap. The gap is cost: Rust's .iter().filter().map().sum() compiles to the same machine code as a hand-written for loop — no allocation, no boxing, no per-element function-call overhead. Python pays a generator frame switch per element.

collect() — one method, chosen by the return type

Python
v: list[int] = list(range(1, 5))              # a list — `list` chose that, not the annotation
s: set[int] = set(range(1, 5))                # a set — a second constructor, named by hand
c: str = "".join(["a", "b", "c"])             # a str — a third, unrelated spelling
r: list[int] = [int(x) for x in ["1", "2"]]   # raises on a bad element; there is no Result to collect into
Rust
let v: Vec<i32> = (1..5).collect();                     // a Vec
let s: HashSet<i32> = (1..5).collect();                 // a HashSet
let s: String = ['a', 'b', 'c'].into_iter().collect();  // a String
let r: Result<Vec<i32>, _> = ["1", "2"].iter().map(|x| x.parse()).collect(); // short-circuits on Err

No Python equivalent: collect() is one generic function whose behaviour is selected entirely by the binding's declared or inferred type, via the FromIterator trait — no return-type-based dispatch exists in Python. list(it), dict(it), set(it) are separate constructors the caller picks by name; nothing lets one call become a list, a dict, or a set purely because of what the result is assigned to.

14 · Modules & Imports

Python
# math_utils.py
def add(a, b):
    return a + b

# main.py
from math_utils import add
import math_utils

add(1, 2)
math_utils.add(1, 2)
Rust
// src/math_utils.rs
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

// src/main.rs
mod math_utils;                // tells Rust to load src/math_utils.rs

use math_utils::add;           // bring into scope
add(1, 2);
math_utils::add(1, 2);         // fully qualified path also works

// Bring multiple items
use std::collections::{HashMap, HashSet};

Visibility — Python vs Rust:

Python Rust Meaning
(no prefix) pub Public
_name (convention only) (no modifier) Private to module
__name (name mangling) pub(crate) Visible within this crate only
N/A pub(super) Visible to parent module only
No Python equivalent: every cell in the Python column is a naming convention — obj._private and even name-mangled obj._Class__name remain fully accessible from any calling code. Rust's visibility is enforced by the compiler; reaching across a module boundary for a non-pub item is a compile error, not a style violation.

15 · Pattern Matching

Python 3.10+ has match. Rust's match is more powerful and exhaustive (the compiler forces you to handle every case).

Python
command = ("move", 10, 20)

match command:
    case ("move", x, y):
        print(f"Move to {x},{y}")
    case ("quit",):
        print("Quit")
    case _:
        print("Unknown")
Rust
let command = ("move", 10, 20);

match command {
    ("move", x, y) => println!("Move to {x},{y}"),
    ("quit", ..) => println!("Quit"),
    _ => println!("Unknown"),
}

// Match on enum variants — the most common use
enum Message {
    Move { x: i32, y: i32 },
    Write(String),
    Quit,
}

let msg = Message::Move { x: 10, y: 20 };

match msg {
    Message::Move { x, y } => println!("Move to {x},{y}"),
    Message::Write(text) => println!("Write: {text}"),
    Message::Quit => println!("Quit"),
    // no _ needed — compiler verifies all variants are covered
}

Guards in match:

Python
x = 15
match x:
    case n if n < 0:
        print("negative")
    case n if n < 10:
        print("small")
    case _:
        print("big")
Rust
let x = 15;
match x {
    n if n < 0 => println!("negative"),
    n if n < 10 => println!("small"),
    _ => println!("big"),
}

Patterns Python's match Can't Express

Python supports or-patterns (|) and capture patterns, but not range patterns or @ bindings.

Python
# Python needs guards for what Rust does with a range pattern
match n:
    case 0: msg = "zero"
    case 1 | 2 | 3: msg = "small"
    case n if 4 <= n <= 9: msg = "medium"      # `case 4..=9` is a SyntaxError
    case n if 10 <= n <= 99: msg = "large"     # and no `@` binding — rebind manually
    case _: msg = "huge"
Rust
let msg = match n {
    0 => "zero",
    1 | 2 | 3 => "small",                  // or-pattern (Python has this)
    4..=9 => "medium",                     // range pattern — no Python equivalent
    big @ 10..=99 => {                     // @ binds the value *while* matching
        println!("two digits: {big}");
        "large"
    }
    _ => "huge",
};

// Struct destructuring with `..` to ignore the rest
struct Config { host: String, port: u16, debug: bool, retries: u8 }

match cfg {
    Config { port: 443, host, .. } => println!("https on {host}"),
    Config { debug: true, .. }     => println!("debug mode"),
    _                              => println!("default"),
}

// Slice patterns — match on length and position
match values.as_slice() {
    []            => println!("empty"),
    [only]        => println!("one: {only}"),
    [first, .., last] => println!("{first} … {last}"),
}
Pattern Rust Python match
Or-pattern 1 | 2 | 3 1 | 2 | 3
Range 4..=9 guard only ❌
Bind while matching big @ 10..=99 guard + rebind ❌
Struct rest Config { host, .. } case Config(host=h) ✅ (partial)
Slice head/tail [first, .., last] case [first, *_, last]
Exhaustiveness compile-time ✅ none ❌

16 · Lifetimes

Python's garbage collector answers "is this object still alive?" at runtime, forever. Rust answers it once, at compile time, using lifetimes. There is no Python equivalent to learn from — this is genuinely new.

A lifetime is a name for a region of code during which a reference is valid. The compiler infers them almost always; you annotate only when it can't tell which input a returned reference came from.

Python
# Python: dangling references are impossible — the GC keeps the object alive
def first_word(s: str) -> str:
    return s.split()[0]      # returns a new string; original may be freed later

word = first_word("hello world")
# `word` is fine no matter what happens to the input
Rust
// Rust: returning a reference means the compiler must prove it outlives the call
fn first_word(s: &str) -> &str {           // lifetime elided — only one input
    s.split_whitespace().next().unwrap()   // borrowed FROM s
}

let sentence = String::from("hello world");
let word = first_word(&sentence);
println!("{word}");

// Uncomment BOTH lines and it stops compiling. Both are needed: the drop is
// only rejected because `word` is still read afterwards.
// drop(sentence);        // error[E0505]: cannot move out of `sentence` because it is borrowed
// println!("{word}");    //             ...borrow later used here

When you must annotate — two inputs, one output, and the compiler can't guess:

Python

No Python equivalent. There is no way to say how long a reference must stay valid. The garbage collector keeps anything still reachable alive, so the question never arises — and neither does the error.

Rust
// 'a says: the return value lives as long as the SHORTER of x and y
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

let result;
let s1 = String::from("long string");
{
    let s2 = String::from("short");
    result = longest(&s1, &s2);    // error[E0597]: `s2` does not live long enough
}                                  // s2 dropped here while still borrowed...
println!("{result}");              // ...borrow later used here

Without lifetimes that snippet is a use-after-free. In Python it's impossible; in C it's a silent security bug; in Rust it's a compile error.

Lifetimes in structs — any struct holding a reference must declare one:

Python

No Python equivalent. An object holding a reference simply keeps it alive. There is no annotation to write, and no way to be told you outlived your data.

Rust
struct Parser<'a> {
    input: &'a str,      // this Parser cannot outlive the string it borrows
    pos: usize,
}

impl<'a> Parser<'a> {
    fn new(input: &'a str) -> Self {
        Parser { input, pos: 0 }
    }
}

'static — lives for the whole program:

Python

No Python equivalent. Every object's lifetime is decided at runtime by reference counting. There is no compile-time “lives for the whole program” to declare.

Rust
let s: &'static str = "baked into the binary";
Concept Python Rust
Object liveness tracked at runtime by GC proven at compile time
Dangling reference impossible (GC) impossible (borrow checker)
Use-after-free impossible compile error
Annotation burden none occasional 'a
Runtime cost GC pauses, refcount churn zero
Mental model: a lifetime isn't something you create. It's you telling the compiler a relationship that already exists in your code. If the annotation feels wrong, the design usually is — often the fix is to return an owned String instead of a &str.

17 · Concurrency

Python — the default build serialises threads behind the GIL, so CPU-bound work needs multiprocessing. The free-threaded build (experimental in 3.13, officially supported in 3.14 via PEP 779) removes that limit, at some single-threaded cost and with an ecosystem still catching up.

Rust — true parallelism, async/await with tokio

Python
import asyncio

async def fetch(url: str) -> str:
    await asyncio.sleep(1)       # simulate I/O
    return f"data from {url}"

async def main():
    results = await asyncio.gather(
        fetch("http://a.com"),
        fetch("http://b.com"),
    )
    print(results)

asyncio.run(main())
Rust
use tokio;

async fn fetch(url: &str) -> String {
    tokio::time::sleep(std::time::Duration::from_secs(1)).await;
    format!("data from {url}")
}

#[tokio::main]
async fn main() {
    let (a, b) = tokio::join!(
        fetch("http://a.com"),
        fetch("http://b.com"),
    );
    println!("{a}\n{b}");
}

True parallelism with rayon (no Python equivalent without multiprocessing):

Python
from multiprocessing import Pool

numbers = list(range(1_000_000))
with Pool(4) as pool:                                  # processes, not threads — the GIL
    total = sum(pool.map(sum, [numbers[i::4] for i in range(4)]))
print(total)                                           # 499999500000, pickled both ways
Rust
use rayon::prelude::*;

let numbers: Vec<i64> = (0..1_000_000).collect();
let sum: i64 = numbers.par_iter().sum();  // parallel across all CPU cores

Fearless Concurrency — Data Races Are a Compile Error

This is the headline Rust feature with no Python counterpart. The GIL protects the interpreter's own memory — you will never see a torn object or a corrupted refcount — but it does not protect your logic. Any read-modify-write like counter += 1 is several bytecodes and can be interrupted partway, as below. Rust protects you with the type system, at zero runtime cost, and still gives you all your cores. On a free-threaded build the GIL's incidental serialisation is gone too, which makes this class of bug easier to hit, not harder.

Two marker traits do the work:

  • Send — this type can be moved to another thread
  • Sync — this type can be referenced from multiple threads at once

The compiler derives them automatically and refuses code that violates them.

Python
# Python — this compiles, runs, and is WRONG. No tool warns you.
import threading

counter = 0

def bump():
    global counter
    for _ in range(100_000):
        counter += 1      # read-modify-write, not atomic

ts = [threading.Thread(target=bump) for _ in range(4)]
[t.start() for t in ts]
[t.join() for t in ts]
print(counter)            # < 400000, non-deterministically. A real data race.
Rust
use std::thread;

let mut counter = 0;

// The naive version DOESN'T COMPILE — the error is the feature
let handles: Vec<_> = (0..4).map(|_| {
    thread::spawn(|| {
        for _ in 0..100_000 {
            counter += 1;    // error[E0597]: `counter` does not live long enough
        }                    // spawn requires 'static — a borrow can't escape into a thread
    })
}).collect();
Reaching for move here looks like the fix and isn't: counter is an i32, which is Copy, so move || hands each thread its own private copy. That version does compile, runs clean, and leaves the original counter at 0 — the bug Python would have given you, just spelled differently. Sharing one counter needs shared ownership, which is what Arc provides.

The compiler forces you to the correct version:

Python
import threading

counter = 0
lock = threading.Lock()

def work():
    global counter                       # no shared ownership — just module scope
    for _ in range(100_000):
        with lock:                       # drop this line and it still runs
            counter += 1

threads = [threading.Thread(target=work) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(counter)                           # 400000 — only because we remembered the lock
Rust
use std::sync::{Arc, Mutex};
use std::thread;

let counter = Arc::new(Mutex::new(0));       // Arc = shared, Mutex = synchronised

let handles: Vec<_> = (0..4).map(|_| {
    let counter = Arc::clone(&counter);      // each thread gets its own handle
    thread::spawn(move || {
        for _ in 0..100_000 {
            *counter.lock().unwrap() += 1;   // lock, mutate, auto-unlock on drop
        }
    })
}).collect();

for h in handles { h.join().unwrap(); }
println!("{}", *counter.lock().unwrap());    // exactly 400000, every time

Mutex<T> owns its data — you cannot forget to lock

Python
lock = threading.Lock()
counter = 0

with lock:
    counter += 1        # correct

counter += 1            # ALSO legal — the lock is advisory, nothing enforces it
Rust
let counter = Mutex::new(0);
// *counter += 1;                 // impossible — the i32 lives INSIDE the Mutex
*counter.lock().unwrap() += 1;    // the only way in is through the lock

And the lock releases itself via Drop (9. Structs (Python Classes — Data Part)) — no finally, no forgotten unlock, even on panic.

Summary

Concern Python Rust
Data races possible, silent compile error
True parallel threads GIL build: no. Free-threaded (3.13+): yes native
Parallelism workaround multiprocessing (separate memory) none needed
Lock enforcement advisory with lock: Mutex<T> owns the data
Unlock on panic needs try/finally automatic via Drop
Send / Sync marker traits none compiler-checked
Sharing across threads any object; no compiler check either way must be Arc<T> and Send
Data-parallel iteration none without processes rayon .par_iter()
Rust calls the guarantee "fearless concurrency": if it compiles, it has no data races. The cost is that the borrow checker rejects concurrent code Python would happily run — and break.

18 · Testing

Python
# test_math.py
import pytest

def add(a, b):
    return a + b

def test_add():
    assert add(2, 3) == 5

def test_add_negative():
    assert add(-1, 1) == 0

def test_raises():
    with pytest.raises(TypeError):
        add("a", 1)
Rust
fn add(a: i32, b: i32) -> i32 {
    a + b
}

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

    #[test]
    fn test_add() {
        assert_eq!(add(2, 3), 5);
    }

    #[test]
    fn test_add_negative() {
        assert_eq!(add(-1, 1), 0);
    }

    #[test]
    #[should_panic]
    fn test_panics() {
        panic!("this is expected");
    }
}
Python (pytest) Rust Purpose
assert x == y assert_eq!(x, y) Equality
assert x != y assert_ne!(x, y) Inequality
assert x assert!(x) Truthiness
pytest.raises(E) #[should_panic] Expect panic
@pytest.mark.skip #[ignore] Skip a test
conftest.py fixtures no direct equiv — use helper fns Shared setup

19 · Formatting & Printing

Python
name = "Alice"
score = 42
pi = 3.14159

print(f"Name: {name}")
print(f"Score: {score:>10}")       # right-align, width 10
print(f"Pi: {pi:.2f}")             # 2 decimal places
print(f"{name!r}")                 # repr
Rust
let name = "Alice";
let score = 42;
let pi = 3.14159_f64;

println!("Name: {name}");
println!("Score: {score:>10}");    // right-align, width 10
println!("Pi: {pi:.2}");           // 2 decimal places
println!("{name:?}");              // Debug format (like repr)

// Named args
println!("{val:.2}", val = pi);

// Padding and fill
println!("{:0>5}", 42);            // "00042"
println!("{:-<10}", "hi");         // "hi--------"

New in 1.98 — Fast integer-to-string without heap allocation

No Python equivalent: Python's str(n) always heap-allocates a new string object — there's no way to format a number into caller-owned memory instead. n.to_string() in Rust allocates too, but format_into writes into a stack buffer instead, with no heap allocation at all:

Python

No Python equivalent. str(n) always allocates a new string object. There is no way to format a number into memory the caller already owns.

Rust
use core::fmt::NumBuffer;

let mut buf = NumBuffer::new();
let s: &str = 42i32.format_into(&mut buf);   // borrows from buf — no heap allocation
println!("{s}");
Matches the long-standing itoa crate's performance, now built into std — worth reaching for in hot loops instead of .to_string().

20 · Common Gotchas for Python Developers

Integer overflow

Python
# Python: integers are arbitrary precision
x = 10 ** 100    # works fine
Rust
// Rust: integers have fixed size — overflow panics in debug, wraps in release
let x: u8 = 255;
// let y = x + 1;    // panics in debug mode!

// Use checked/wrapping/saturating arithmetic explicitly
let y = x.checked_add(1);          // Option<u8> — None on overflow
let y = x.wrapping_add(1);         // wraps to 0
let y = x.saturating_add(1);       // stays at 255
No Python equivalent: Python's int is arbitrary precision, so there's no fixed width to overflow and nothing for checked/wrapping/saturating semantics to apply to — not even a library offers this, because the underlying problem doesn't exist in Python.

No implicit type conversion

Python
x = 1 + 1.5      # Python auto-converts int to float
Rust
let x = 1_i32;
// let y = x + 1.5;    // compile error: mismatched types
let y = x as f64 + 1.5;            // explicit cast required

Strings are not indexable by character

Python
s = "hello"
c = s[0]         # 'h'
Rust
let s = "hello";
// let c = s[0];        // compile error!
let c = s.chars().nth(0);          // Option<char>
let c = &s[0..1];                  // byte slice — unsafe for multibyte chars
Full explanation of why: 8. Strings and Their Methods.

Moving vs copying

Python
a = [1, 2, 3]
b = a              # both point to same list
Rust
let a = vec![1, 2, 3];
let b = a;                          // a is MOVED into b — a is gone
// println!("{:?}", a);             // compile error

let a = vec![1, 2, 3];
let b = a.clone();                  // explicit copy — both valid

21 · Macros

Python metaprogramming runs at runtime: decorators wrap functions at import, metaclasses fire at class creation, getattr resolves names during execution. Rust's runs at compile time — macros rewrite source before type checking, so the generated code is fully checked and costs nothing at runtime.

The ! you have been typing all along (println!, vec!, format!) marks a macro call.

Why Rust Needs Macros

Three concrete reasons, all of them consequences of things Rust lacks.

Reason 1 — Rust functions cannot be variadic

A Rust fn takes a fixed number of parameters. Always. So println! cannot be a function:

Python
# Python: *args is free
def log(fmt, *args):
    print(fmt.format(*args))

log("{} + {} = {}", 1, 2, 3)
log("{}", 1)
Rust
println!("{}", 1);
println!("{} + {} = {}", 1, 2, 3);      // same macro, different arity

Reason 2 — compile-time validation

Because a macro sees your code as syntax, it can check things before the program runs.

Python
"{} {}".format(1)          # IndexError — at runtime, in production
Rust
println!("{} {}", 1);      // COMPILE ERROR: 2 placeholders, 1 argument

The same trick scales much further. sqlx::query! validates SQL against your real database schema at compile time. Python physically cannot do this — there is no compile step to do it in.

Reason 3 — generating implementations

Without inheritance, Rust would need you to hand-write Debug, Clone, PartialEq, serialization, and CLI parsing for every struct. #[derive(...)] generates them.

Python
@dataclass          # runtime: builds methods when the class is defined
class Point:
    x: float
    y: float
Rust
#[derive(Debug, Clone, PartialEq)]   // compile time: generates impl blocks
struct Point { x: f64, y: f64 }

What macros are not

They are not runtime reflection. A macro cannot inspect a value, look up a field by string name, or decide anything based on data. It only rewrites syntax. Rust's answer to getattr(obj, name) is an enum or a HashMap of closures, never a macro.

The Four Kinds of Macro

Kind Looks like Defined with Use it for
Declarative vec![1, 2] macro_rules! pattern-matching on syntax, variadics
Derive #[derive(Clone)] proc-macro crate generating trait impls for a type
Attribute #[tokio::main] proc-macro crate rewriting or wrapping an item
Function-like sqlx::query!("...") proc-macro crate arbitrary compile-time processing

The last three are collectively procedural macros: real Rust programs that run during compilation, taking a token stream in and returning one out. They live in their own crate with proc-macro = true in Cargo.toml.

You will use all four constantly and probably write only the first. Authoring proc macros needs syn and quote — reach for it only when generating trait impls across many types.

Reading macro_rules Macros

You will read these far more often than you write them. A macro_rules! definition is a series of rules, each mapping a syntax pattern to a replacement. It is match, but for source code.

Python

No Python equivalent. There is no macro layer that matches on syntax. A decorator runs at runtime on a function that has already been parsed and compiled.

Rust
macro_rules! square {
    ($x:expr) => {           // pattern  =>  expansion
        $x * $x
    };
}

let n = square!(3 + 1);      // expands to (3 + 1) * (3 + 1)  =  16
$x:expr captures the whole expression as a unit, so 3 + 1 doesn't expand to the broken 3 + 1 * 3 + 1. Rust macros are not C's text substitution.

Fragment specifiers

$name:kind captures a piece of syntax. The kind tells the compiler what to expect.

Specifier Matches Example
expr an expression 2 + 2, foo(), x
ident an identifier or keyword my_var, Point
ty a type Vec<String>, &str
pat a pattern Some(x), 1..=9
literal a literal 42, "hi", true
block a braced block { let a = 1; a }
stmt a single statement let x = 5
path a path std::collections::HashMap
item a whole item a fn, struct, or mod
vis a visibility marker pub, pub(crate), nothing
lifetime a lifetime 'a, 'static
meta attribute contents derive(Debug)
tt one token tree — most flexible almost anything

Repetition

Syntax Means
$( ... )* zero or more
$( ... )+ one or more
$( ... )? zero or one
$( ... ),* zero or more, comma-separated
$( ... ),+ $(,)? one or more, comma-separated, optional trailing comma

The body repeats once per captured item, using the same $( ... ) wrapper:

Python

No Python equivalent. No syntactic repetition operator. *args collects values at runtime; this matches tokens at compile time.

Rust
macro_rules! print_all {
    ( $( $item:expr ),* ) => {
        $(                              // repeat this block per item
            println!("{}", $item);
        )*
    };
}

print_all!("a", 1, true);       // three println! calls

Multiple rules

Rules are tried top to bottom, first match wins — so put specific cases first.

Python

No Python equivalent. No dispatch on syntactic shape. A Python function has one signature and inspects its arguments at runtime.

Rust
macro_rules! my_vec {
    () => { Vec::new() };                       // empty case
    ($elem:expr; $n:expr) => {                  // my_vec![0; 10]
        vec![$elem; $n]
    };
    ($( $item:expr ),+ $(,)?) => {{             // my_vec![1, 2, 3]
        let mut v = Vec::new();
        $( v.push($item); )+
        v
    }};
}
Note the double braces {{ ... }} in that last rule. The outer pair belongs to the macro definition; the inner pair makes the expansion a single block expression so it evaluates to v. Forgetting the inner braces is the most common macro_rules! mistake.

Hygiene

Variables a macro introduces cannot collide with the caller's — a guarantee Python's exec-based tricks can never make.

Python

No Python equivalent. No hygiene, because there is no expansion step. exec() shares the caller’s namespace and will happily clobber a local.

Rust
macro_rules! make_temp {
    () => { let tmp = 99; };
}

let tmp = 1;
make_temp!();          // introduces a *different* tmp
println!("{tmp}");     // 1 — the caller's tmp is untouched

Hygiene applies to local variables, not to types or functions you name explicitly — those resolve at the call site.

Writing Your Own macro_rules Macro

A HashMap literal — because Rust has no dict syntax

Python
scores = {                          # dict literal — grammar, not a macro
    "Alice": 100,
    "Bob": 85,
}

# the ":" separator is baked into the parser; you cannot invent your own
scores = dict(Alice=100, Bob=85)    # or the constructor, keys limited to identifiers
scores = {**scores, "Carol": 92}    # or unpack into a fresh dict

nums = [1, 2, 3]                    # and [] is why Python never needed a vec! either
Rust
macro_rules! hashmap {
    ( $( $key:expr => $val:expr ),* $(,)? ) => {{
        let mut m = std::collections::HashMap::new();
        $( m.insert($key, $val); )*
        m
    }};
}

let scores = hashmap!{
    "Alice" => 100,
    "Bob"   => 85,
};

That is the closest Rust gets to Python's {"Alice": 100}. You invent the => separator yourself — a macro pattern can use any token arrangement you like.

Timing a block — Rust's answer to a decorator

The sharpest Python contrast in the topic. Python wraps a function at runtime with a decorator; Rust rewrites the code at compile time with a macro. Same intent, opposite mechanism, and the Rust version has no wrapper frame.

Python
import time, functools

def timed(fn):                       # runtime wrapper
    @functools.wraps(fn)
    def inner(*args, **kwargs):
        start = time.perf_counter()
        try:
            return fn(*args, **kwargs)
        finally:
            print(f"{fn.__name__}: {time.perf_counter() - start:.3f}s")
    return inner

@timed
def load_data(): ...
Rust
macro_rules! timed {
    ($label:expr, $body:block) => {{
        let start = std::time::Instant::now();
        let result = $body;
        println!("{}: {:?}", $label, start.elapsed());
        result                        // the block's value passes through
    }};
}

let data = timed!("load_data", {
    expensive_call()
});

Reducing trait-impl boilerplate

The genuinely load-bearing use case: implementing the same trait for many types.

Python
from typing import Protocol

class Describe(Protocol):
    def describe(self) -> str: ...

def impl_describe(cls, name):        # a "macro" that can only run at runtime
    cls.describe = lambda self: f"{name} value: {self}"

class Integer(int): pass    # int itself rejects the patch:
class Float(float): pass    # "cannot set attribute of immutable type 'int'"

for cls, label in [(Integer, "integer"), (Float, "float")]:
    impl_describe(cls, label)

def show(d: Describe) -> None:
    print(d.describe())

show(Integer(7))            # integer value: 7
show(Float(2.5))            # float value: 2.5

# nothing verified either class satisfies Describe — the impl is a dict write,
# and bool is not an acceptable base type, so it cannot receive one at all
Rust
trait Describe {
    fn describe(&self) -> String;
}

macro_rules! impl_describe {
    ( $( $t:ty => $name:expr ),* $(,)? ) => {
        $(
            impl Describe for $t {
                fn describe(&self) -> String {
                    format!("{} value: {}", $name, self)
                }
            }
        )*
    };
}

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

In Python you would loop over a list of classes and setattr methods onto them. Here the impls are real, checked, and zero-cost.

Derive Macros in Practice

Derives are the macros you will use most, and you almost never write them. #[derive(X)] generates impl X for YourType.

Standard library derives

Derive Generates Python analogue
Debug {:?} formatting __repr__
Clone .clone() copy.deepcopy
Copy implicit bitwise copy on assign immutable value semantics
PartialEq / Eq == __eq__
PartialOrd / Ord <, >, sorting functools.total_ordering
Hash usable as a HashMap key __hash__
Default Type::default() dataclass field defaults
Python
from dataclasses import dataclass

@dataclass(frozen=True)      # __repr__, __eq__, __hash__ — built at class-creation time
class UserId:
    value: int = 0           # the Default; the annotation itself is never enforced
Rust
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
struct UserId(u64);

A derive only applies if every field supports it — derive Hash on a struct containing an f64 and it will not compile, because floats are not Hash. The constraint is enforced, not discovered at runtime.

Ecosystem derives

These replace whole Python libraries:

Rust Crate Replaces
#[derive(Serialize, Deserialize)] serde pydantic, dataclasses.asdict, json
#[derive(Error)] thiserror custom Exception subclasses
#[derive(Parser)] clap argparse, click, typer
#[derive(FromRow)] sqlx SQLAlchemy row mapping
#[derive(Builder)] derive_builder keyword arguments
Python
from pydantic import BaseModel, Field

class Config(BaseModel):
    host: str
    port: int = 8080                                 # same role as serde's default
    debug: bool = Field(alias="debugMode")           # rename on the wire
    internal: str = Field(default="", exclude=True)  # skipped on the way out

raw = '{"host": "localhost", "debugMode": true}'
cfg = Config.model_validate_json(raw)                # parse and validate in one step
print(cfg.port, cfg.debug)                           # 8080 True
print(cfg.model_dump())                              # internal never appears

# Config is a real runtime object, rebuilding validators for every call
Rust
use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Debug)]
struct Config {
    host: String,
    #[serde(default = "default_port")]      // helper attributes configure it
    port: u16,
    #[serde(rename = "debugMode")]
    debug: bool,
    #[serde(skip)]
    internal: String,
}

fn default_port() -> u16 { 8080 }

let cfg: Config = serde_json::from_str(raw)?;   // parse and validate in one step

That Config is the equivalent of a pydantic.BaseModel — except the validation code is generated at compile time and there is no runtime schema object at all.

Python
import argparse

parser = argparse.ArgumentParser(description="Process some files")
parser.add_argument("--version", action="version", version="1.0.0")
parser.add_argument("path", help="Input path")          # a string arg, not a doc comment
parser.add_argument("-w", "--workers", type=int, default=4)
parser.add_argument("--dry-run", action="store_true")   # dest becomes args.dry_run

args = parser.parse_args(["in.txt", "--dry-run"])       # parser assembled imperatively
print(args.path, args.workers, args.dry_run)            # in.txt 4 True

args = parser.parse_args(["in.txt", "-w", "8"])
print(args.workers, args.dry_run)                       # 8 False

# args is a plain Namespace built at runtime — no struct, no fields, and
# args.typo is an AttributeError you find when you first hit that branch
Rust
use clap::Parser;

#[derive(Parser)]
#[command(version, about = "Process some files")]
struct Args {
    /// Input path                      <- doc comment becomes the help text
    path: String,

    #[arg(short, long, default_value_t = 4)]
    workers: usize,

    #[arg(long)]
    dry_run: bool,
}

let args = Args::parse();       // full CLI, parsed and type-checked

Derive vs writing the impl by hand

Write it by hand when the generated behaviour is wrong:

Python
class Password:
    def __init__(self, value: str):
        self.value = value

    def __repr__(self) -> str:      # __repr__ is the Debug analogue
        return "Password(***)"      # @dataclass would have printed the secret

p = Password("hunter2")
print(f"{p!r}")                     # Password(***)
print(p.value)                      # hunter2 — still there, just not in the logs
Rust
struct Password(String);

// Derived Debug would print the password into your logs. Don't.
impl std::fmt::Debug for Password {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        write!(f, "Password(***)")
    }
}

Attribute and Function-Like Macros

Attribute macros wrap or rewrite an item

Python
import asyncio

async def main(): ...

asyncio.run(main())          # a call you make at run time, not an annotation

def test_it_works(): ...     # pytest finds it by name, at collection time

# and nothing gates tests out of the build — they are ordinary code that always ships
Rust
#[tokio::main]              // rewrites async main into a sync main + runtime setup
async fn main() { }

#[test]                     // registers the fn with the test harness
fn it_works() { }

#[cfg(test)]                // built in, not a proc macro — see §23
mod tests { }

#[tokio::main] is the clearest parallel to a Python decorator: it takes your function and produces a different one. The difference is that it happens once at compile time, so there is no wrapper frame on the call stack.

Function-like macros do arbitrary compile-time work

Python

No Python equivalent. No compile step to validate against. A wrong column name is a runtime error — at best caught by a test that actually hits the database.

Rust
// Validates the SQL against your real schema — at compile time.
let user = sqlx::query_as!(User, "SELECT id, name FROM users WHERE id = $1", id)
    .fetch_one(&pool)
    .await?;

// Embeds file contents into the binary. No runtime I/O, no missing-file error.
const SCHEMA: &str = include_str!("../schema.sql");
const LOGO: &[u8] = include_bytes!("../logo.png");

// Reads an environment variable during compilation.
const VERSION: &str = env!("CARGO_PKG_VERSION");

Misspell a column in that query_as! and the build fails. The Python equivalent fails in production at 3 a.m.

Standard Library Macro Catalogue

Macro Does Python equivalent
println! / print! write to stdout print()
eprintln! / eprint! write to stderr print(file=sys.stderr)
format! build a String f-string
write! / writeln! format into a writer or buffer f.write(), io.StringIO
vec![] construct a Vec list literal
matches!(x, pat) pattern test as a bool none — match is a statement
dbg!(expr) print file, line, expression, value; return the value print(f"{expr=}")
assert! / assert_eq! / assert_ne! invariant checks, always compiled if not cond: raise AssertionError(...)
debug_assert! invariant checks, debug builds only assert — stripped by python -O, like --release strips this
panic! unrecoverable bug unhandled raise
unreachable! this branch is impossible raise AssertionError
todo! / unimplemented! stub that type-checks as anything ... / NotImplementedError
include_str! / include_bytes! embed a file at compile time open().read() at runtime
env! / option_env! read env var at compile time os.environ at runtime
concat! / stringify! join literals, turn tokens into a string string concat, repr
line! / file! / column! source position inspect, __file__
cfg!(...) config check as a bool expression sys.platform == ...

The panic family is covered in depth in 11. Error Handling. Two others worth calling out:

Python
# no matches! needed — a comparison is already a bool, and match is a statement
is_digit = c.isdigit()
if isinstance(msg, Quit): return

# no dbg! either — print() returns None, so walrus keeps the value in the expression
total = (subtotal_tax := subtotal + tax) * quantity
print(f"{subtotal_tax = }")            # subtotal_tax = 42
Rust
// matches! — because Rust's `match` is an expression, not a bool
let is_digit = matches!(c, '0'..='9');
if matches!(msg, Message::Quit) { return; }

// dbg! — returns its argument, so wrap an expression without restructuring
let total = dbg!(subtotal + tax) * quantity;
// prints: [src/main.rs:12] subtotal + tax = 42

dbg! writes to stderr and should not ship — treat it like a stray print().

Debugging Macros

Macro errors are the worst error messages in Rust, because the compiler reports problems in code you never wrote.

See the generated code

Shell
cargo install cargo-expand
cargo expand                       # whole crate, macros expanded
cargo expand path::to::module      # one module
cargo expand --test my_test        # a test target

This is the single most useful macro-debugging tool. Run it once on a struct with #[derive(Serialize)] — seeing the generated impl demystifies the whole system.

Common failures

Symptom Cause
no rules expected this token input matches no rule — check separators and trailing commas
Expansion is a statement, you wanted a value missing inner braces {{ ... }}
expected expression, found statement wrong fragment specifier — stmt where you meant expr
cannot find value in generated code hygiene — the macro cannot see the caller's locals unless passed in
the trait bound is not satisfied on a derive a field does not support the derived trait
Error points into a macro you did not write run cargo expand and read the real code

Ordering rule

Declarative macros must be defined before use in source order — unlike functions. Define them near the crate root, or mark them #[macro_export] and use them.

Python

No Python equivalent. Nothing to export. Python has no macros, so there is no separate namespace for them and no visibility rule to learn.

Rust
#[macro_export]          // makes it available as crate::my_macro
macro_rules! my_macro { () => {}; }

When Not to Use a Macro

Macros cost you readable errors, IDE support, and compile time. Most of the time something simpler works.

You want Use this instead Not a macro because
The same logic for several types generics with trait bounds the compiler checks it, errors are readable
Shared behaviour across types a trait with default methods that is what traits are for
A variable number of same-typed args &[T] slice f(&[1, 2, 3]) is close enough
Optional or named parameters config struct + Default, or a builder idiomatic, discoverable, documented
A value computed once const fn or const evaluated at compile time already
Code generated from an external schema a build.rs build script full Rust, easier to debug
Runtime dispatch on a string name enum or HashMap<String, fn()> macros cannot see runtime values

Reach for a macro when: you need true variadics, compile-time validation of a literal, one trait implemented across many concrete types, or a small DSL. That is roughly the whole list.

Macros vs Python Metaprogramming

Aspect Python decorators and metaclasses Rust macros
Runs at import time or runtime compile time
Operates on live objects the syntax tree
Can inspect values yes no
Can generate new types and impls limited yes
Errors surface at runtime at compile time
Runtime cost wrapper frame per call zero
Variadic arguments *args / **kwargs macros only
Validate SQL, JSON, regex early impossible yes
Identifier hygiene none guaranteed for locals
Debuggability good — plain Python poor — needs cargo expand
Ships in the artifact yes, the wrapper is live code no, expanded away
Python construct Nearest Rust equivalent
@dataclass #[derive(Debug, Clone, PartialEq, Default)]
@timer / @retry macro_rules! wrapping a block
pydantic.BaseModel #[derive(Serialize, Deserialize)]
argparse / typer #[derive(Parser)]
asyncio.run(main()) #[tokio::main]
metaclass registering subclasses derive macro, or the inventory crate
setattr in a loop macro_rules! generating impls
getattr(obj, name) no macro equivalent — use an enum or map
exec / eval nothing, deliberately
Rule of thumb: treat macro_rules! the way you would treat a Python metaclass. Reading them is a core skill; writing them is occasionally right; writing your third one in a week means you missed a trait.

22 · Unsafe Rust

Python has no concept of "unsafe" because the interpreter is always in charge. Rust's guarantees are enforced by the compiler, so it offers an explicit escape hatch for the handful of things the compiler can't verify.

unsafe unlocks exactly five abilities — and nothing else:

  1. Dereference a raw pointer
  2. Call an unsafe function (including all FFI)
  3. Access or modify a mutable static
  4. Implement an unsafe trait (Send / Sync by hand)
  5. Access a union field
Python
import ctypes

x = ctypes.c_int(5)
p = ctypes.pointer(x)         # a real pointer — but from an FFI library, not the language
p.contents.value += 1         # deref is plain attribute access; nothing marks it dangerous
print(x.value)                # 6
# closest analogue, not an equivalent: there is no checked region here to opt out of
Rust
let mut x = 5;
let p = &mut x as *mut i32;       // creating a raw pointer is SAFE

unsafe {
    *p += 1;                      // dereferencing it needs unsafe
}
println!("{x}");                  // 6

What unsafe does NOT do: it doesn't disable the borrow checker, and it doesn't turn off type checking. It only says "I have verified these five operations by hand."

The real use case — calling C

Python
# Python's escape hatch: ctypes / cffi / a C extension module
import ctypes
libc = ctypes.CDLL("libc.so.6")
libc.abs(-42)                     # no type safety whatsoever
Rust
// Rust's FFI — declare the signature, call inside unsafe
unsafe extern "C" {
    fn abs(input: i32) -> i32;
}

let n = unsafe { abs(-42) };      // 42
Variadic C functions like printf can be declared in an extern block and called this way — that has been stable for years. Defining one in Rust is still nightly-only (#![feature(c_variadic)], tracking issue #44930), so on stable you can call C's variadics but not write your own.

The idiom — wrap unsafe in a safe API

This is how Vec, String, and RefCell are all built — a small audited unsafe core behind an interface that cannot be misused.

Python

No Python equivalent. No aliasing rules to uphold, and no unsafe to scope. Two mutable views of one list are ordinary, unremarkable Python.

Rust
pub fn split_at_mut(slice: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    assert!(mid <= slice.len());              // check the invariant ourselves
    let len = slice.len();
    let ptr = slice.as_mut_ptr();
    unsafe {
        (
            std::slice::from_raw_parts_mut(ptr, mid),
            std::slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}
// Callers never write `unsafe` — the guarantee is restored at the boundary.
Python Rust
Default safety runtime-checked by interpreter compile-time proven
Escape hatch ctypes, C extensions unsafe blocks
Scope of the hatch whole process the block, five operations
Auditability scattered across the codebase grep unsafe finds all of it
Rule: as an application developer you will likely never write unsafe. It exists so that the standard library and hardware-facing crates can be written in Rust rather than in C.

23 · Conditional Compilation and Feature Flags

Python decides everything at runtime: if sys.platform == "linux", try: import ujson. The unused branch still ships in your package and still costs an import check. Rust decides at compile time — excluded code isn't compiled, isn't type-checked against your target, and isn't in the binary.

Compile-time conditionals with cfg

Python
import sys

if sys.platform == "win32":
    def config_dir(): return "C:\\ProgramData"
else:
    def config_dir(): return "/etc"
# both branches parse and ship on every platform
Rust
#[cfg(target_os = "windows")]
fn config_dir() -> &'static str { "C:\\ProgramData" }

#[cfg(not(target_os = "windows"))]
fn config_dir() -> &'static str { "/etc" }
// only one version exists in the compiled binary

#[cfg(debug_assertions)]
fn log_internal(msg: &str) { eprintln!("[dbg] {msg}"); }

#[cfg(test)]
mod tests { }                     // you've already seen this one in §18

#[cfg(all(unix, target_arch = "x86_64"))]
fn fast_path() { }                // combinators: all / any / not

Cargo features — optional dependencies and APIs

The rough equivalent of pip install package[extra], but the flags also gate code.

TOML · Python
# pyproject.toml equivalent
[project.optional-dependencies]
postgres = ["asyncpg"]
TOML · Rust
# Cargo.toml
[features]
default = ["json"]
json = ["serde", "serde_json"]
postgres = ["sqlx"]

[dependencies]
serde = { version = "1", optional = true }
serde_json = { version = "1", optional = true }
sqlx = { version = "0.7", optional = true }
Python
try:
    import orjson              # extras: pyproject.toml json = ["orjson"]
except ImportError:
    orjson = None              # checked at run time; the body below parses either way

def to_json(self): return orjson.dumps(self).decode()
# to_json(obj) → AttributeError: 'NoneType' object has no attribute 'dumps'
Rust
#[cfg(feature = "json")]
pub fn to_json(&self) -> String {
    serde_json::to_string(self).unwrap()
}
// Build without --features json and this method does not exist.
Shell
cargo build                              # default features
cargo build --no-default-features        # bare minimum
cargo build --features "postgres json"   # opt in
Capability Python Rust
Platform branching runtime if sys.platform #[cfg(target_os)], compiled out
Optional dependency extras_require + try: import optional = true + #[cfg(feature)]
Debug-only code if __debug__ #[cfg(debug_assertions)]
Dead branch in artifact shipped absent
Wrong-platform code type-checked no no (that's the point)
Gotcha: code behind an inactive #[cfg] is not compiled, so it's also not checked. It rots silently. CI should build the feature combinations you actually support.

24 · Zero-Cost Abstractions and the Runtime Story

The phrase you'll hear constantly is zero-cost abstraction: using the nice high-level construct compiles to the same machine code as the ugly manual one. Python's abstractions all cost something at runtime, because there's an interpreter in the middle.

What "zero-cost" means concretely

Abstraction Rust runtime cost Python runtime cost
Iterator chain none — compiles to a loop generator frame per element
Generics (<T>) none — monomorphized erased, dynamic dispatch anyway
impl Trait none — static dispatch n/a, always dynamic
Newtype (struct Meters(f64)) none — same layout as f64 full object, ~48 bytes
Option<Box<T>> none — null-pointer optimised None check per access
? operator a branch exception machinery
Drop / RAII none — just a call at scope end GC bookkeeping
Bounds checking one comparison (often elided) one per index

No runtime, no GC, no interpreter

Dockerfile · Python
# Deployment: ship source + an interpreter + a resolved dependency tree
FROM python:3.14-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt      # ~50-150 MB image, needs Python present
COPY . .
CMD ["python", "app.py"]
Dockerfile · Rust
# Deployment: ship one file
FROM rust:1 AS build
WORKDIR /src
RUN rustup target add x86_64-unknown-linux-musl   # static link — nothing to ship alongside
COPY . .
RUN cargo build --release --target x86_64-unknown-linux-musl

FROM scratch                             # literally an empty image
COPY --from=build /src/target/x86_64-unknown-linux-musl/release/app /app
CMD ["/app"]                             # ~5-15 MB, no interpreter, no deps
The musl target is what makes FROM scratch work. A default cargo build --release links dynamically against glibc, so the binary needs ld-linux and libc.so.6 at runtime — neither exists in scratch, and the container exits immediately. Static linking, or a distroless/cc base, is the price of the one-file story.
Property Python Rust
Artifact source + interpreter + site-packages one static binary
Startup interpreter boot + imports (~50-300 ms) ~1 ms
Memory baseline ~15-30 MB ~1-3 MB
Garbage collector refcounting (deterministic) + a cycle collector (not) none — but Rc/Arc cycles leak (see §25)
GIL default build: yes. Free-threaded build: no (3.13+) none
Dependencies at runtime must be installed compiled in
Cross-compilation n/a (ship source) cargo build --target ...
Errors found before shipping type hints, if you run a checker the entire type system

The trade-off, stated honestly

Python wins Rust wins
Time to first working version ✅ minutes hours
Iteration speed ✅ no compile step cargo check is fast but nonzero
Exploratory / notebook work ✅ REPL, Jupyter poor fit
Library breadth for ML/data ✅ overwhelming narrow
Runtime performance 10-100× slower
Memory footprint ✅ irrelevant for scripts ✅ for services
Correctness before runtime duck typing, hope ✅ compiler proofs
Long-lived services, CLIs, agents fine
Refactoring a large codebase risky ✅ compiler finds every site
The honest summary: Rust moves work from runtime (where your users find the bugs) to compile time (where you do). You pay for that in up-front friction. On a script you run twice, that's a bad trade. On a service you run for three years, it's an excellent one.

25 · What Python Has That Rust Doesn't

The gaps run both ways. These are the Python conveniences you will miss, and what to reach for instead.

Function signatures — no defaults, no kwargs, no varargs

Rust has none of this. The workarounds:

Python
def connect(host, port=5432, *, timeout=30, retries=3, **extra):
    ...

connect("db.local", timeout=5)      # skip what you don't care about
Rust
// 1. Separate constructors for the common cases
impl Client {
    fn new(host: &str) -> Self { /* defaults */ }
    fn with_timeout(host: &str, timeout: u64) -> Self { }
}

// 2. A config struct plus Default (closest to keyword args)
#[derive(Default)]
struct Options { timeout: u64, retries: u8 }

Client::connect("db.local", Options { timeout: 5, ..Default::default() });

// 3. The builder pattern (what real crates do)
Client::builder()
    .host("db.local")
    .timeout(5)
    .build()?;

// 4. Macros, for genuine variadics — see §21

Everything else

Python feature Rust status Reach for instead
Default / keyword arguments none config struct + Default, or a builder
*args / **kwargs none a slice, a struct, or a macro
Exceptions / try-except none Result<T, E> + ? (§11)
finally none Drop (§9) — usually better
Arbitrary-precision int none i128, or the num-bigint crate
Truthiness (if my_list:) none if !v.is_empty() — explicit bool only
for ... else none if !iter.any(...) after the loop
Chained comparison a < b < c none a < b && b < c
Negative indexing xs[-1] none xs.last()Option<&T>
Comprehensions none iterator chains (§13)
REPL / Jupyter none official evcxr, or cargo run on a scratch bin
eval / exec none intentionally impossible
Monkeypatching forbidden extension traits (§12)
Dynamic import / getattr none enums, trait objects, or a registry
Multiple inheritance / MRO none traits + composition
Duck typing none traits — you must declare conformance
Runtime introspection minimal std::any::Any, deliberately clumsy
Generators (yield) unstable hand-written Iterator, or async-stream for streams (gen blocks are still nightly)
One obvious async runtime none pick tokio (usually), or smol for something lighter
Reference cycles collected for you none two Rcs pointing at each other leak forever — break the cycle yourself with Weak<T> (Rc::downgrade, then .upgrade() -> Option<Rc<T>>). Parent holds Rc, child holds Weak. CPython's cycle collector does this for free
pip install and go slower cargo add + a compile
Fast iteration loop cargo check in watch mode
Two of these deserve emphasis, because they shape how Rust code looks: the absence of keyword arguments is why every mature Rust crate has a builder, and the absence of exceptions is why every signature is honest about failing.

Feature Gap Index

Everything in this doc that Rust has and Python has no equivalent for, in one place.

Feature Section
Immutability by default, shadowing 2. Variables
char, [T; N] arrays, newtype pattern, never type ! 3. Data Types
Homogeneous Vec/array/HashMap enforced at compile time (tuples are the one exception), fixed-length arrays as a distinct type, &[T] slices, Eq + Hash as a compile-time bound 7. Collections
Owned vs borrowed strings, UTF-8 correctness enforced, Cow 8. Strings and Their Methods
Generics with enforced trait bounds, const fn, fn pointers as their own type 4. Functions
match as an exhaustive expression 5. Control Flow
Block / if / loop as expressions, break with value 5. Control Flow
Labeled break and continue 5. Control Flow
if let, while let, let ... else 5. Control Flow
Deterministic Drop / RAII 9. Structs (Python Classes — Data Part)
Struct update syntax, enforced field privacy 9. Structs (Python Classes — Data Part)
Data-carrying enum variants 10. Enums (Python Enum and tagged unions)
Errors in the type signature, ? operator with automatic From conversion 11. Error Handling
Panic vs Result as separate channels, unreachable!, todo! 11. Error Handling
Static vs dynamic dispatch as a choice 12. Traits (Python Protocols and ABCs)
Associated types, blanket impls, orphan rule 12. Traits (Python Protocols and ABCs)
Fn / FnMut / FnOnce, move closures, collect() return-type polymorphism 13. Closures & Iterators
Ownership, borrowing, slices as zero-copy views 6. Owner and Borrower — The Concept Python Doesn't Have
Explicit sharing — Rc, Arc, RefCell; Copy vs move as a static, per-type fact 6. Owner and Borrower — The Concept Python Doesn't Have
Enforced module/field visibility (pub, pub(crate), pub(super)) 14. Modules & Imports
Range patterns, @ bindings 15. Pattern Matching
Send / Sync — data races as compile errors 17. Concurrency
Mutex<T> owning its data, true parallelism 17. Concurrency
Stack-buffer integer formatting (NumBuffer, format_into) 19. Formatting & Printing
Explicit overflow control (checked/wrapping/saturating) on fixed-width ints 20. Common Gotchas for Python Developers
Lifetimes 16. Lifetimes
Compile-time macros, variadics, checked format strings 21. Macros
unsafe as a scoped, greppable escape hatch 22. Unsafe Rust
#[cfg] and Cargo feature flags 23. Conditional Compilation and Feature Flags
Zero-cost abstractions, no GC, single static binary 24. Zero-Cost Abstractions and the Runtime Story
(the reverse direction) 25. What Python Has That Rust Doesn't

Quick Reference

Concept Python Rust
Print print("hi") println!("hi")
Variable x = 5 let x = 5;
Mutable variable x = 5 (always mutable) let mut x = 5;
Constant X = 5 (convention) const X: i32 = 5;
String "hello" "hello" (&str) or String::from("hello")
String interpolation f"hi {name}" format!("hi {name}")
List [1, 2, 3] vec![1, 2, 3]
Dict {"a": 1} HashMap::from([("a", 1)])
None None None (inside Option<T>)
Null check if x is not None if let Some(x) = ...
Exception raise ValueError("x") return Err(...) or bail!("x")
Try/catch try/except match result { Ok(...) Err(...) }
Propagate error raise inside except ? operator
Type hint def f(x: int) -> str fn f(x: i32) -> String
Lambda lambda x: x * 2 |x| x * 2
List comprehension [x*2 for x in xs] xs.iter().map(|x| x*2).collect()
Class class Foo: struct Foo { } + impl Foo { }
Interface/Protocol Protocol / ABC trait
Inheritance class B(A): not supported — use traits + composition
Decorator @decorator #[attribute]
Module import foo mod foo; + use foo::bar;
Package directory + __init__.py directory + mod.rs
Async function async def f(): async fn f()
Await await x x.await

Further Reading