A side-by-side tutorial — every concept shown in Python first, then its Rust equivalent
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.
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.
| 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 |
print("Hello, world!")
fn main() {
println!("Hello, world!");
}
Key differences:
fn main() entry point — Python just runs top to bottomprintln! is a macro (the ! is the tell) — macros are expanded at compile time;cargo run, not python hello.pyPython is dynamic and mutable by default; Rust is statically typed and immutable by default.
name = "Alice"
age = 30
age = 31 # reassign freely
pi = 3.14
is_valid = True
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;
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 mutablemutlet to change type: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
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
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.
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.
| 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 |
| 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 |
bool/char scalar, and only tuples/arrays/() compound — String, 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: one string type
greeting = "hello"
greeting = greeting + " world" # creates a new string
// &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)
&str for function parameters (read-only), use String when you need to own or modify the data.
char — a single Unicode scalarPython has no character type; s[0] returns a one-element str. Rust's char is a distinct 4-byte type.
c = "hello"[0] # still a str, just length 1
type(c) # <class 'str'>
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
buf = [0] * 32 # a list that happens to have 32 items; can grow
buf.append(1) # now 33 — nothing stops you
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.
Python's NewType is erased by the interpreter; nothing stops you passing the wrong one.
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
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
!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.
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
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 |
u8 … u128 |
int |
No width or signedness |
def add(a: int, b: int) -> int:
return a + b
result = add(3, 4)
print(result) # 7
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:
return exists but is idiomatic only for early returns# 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
// 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
}
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.
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
// 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:
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
fn describe<T>(item: T) -> String
where
T: std::fmt::Display + Clone,
{
format!("{}", item.clone())
}
const fn — functions that run at compile timePython computes everything at runtime. A const fn result can be baked into the binary and used where a constant is required.
BUFFER_SIZE = 8 * 1024 # computed at import time, every run
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
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.
fn as its own concrete typeEvery 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.
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]
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)];
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.
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 |
*args/**kwargs. See 25. What Python Has That Rust Doesn't.
x = 10
if x > 5:
print("big")
elif x == 5:
print("five")
else:
print("small")
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 ternary
label = "even" if x % 2 == 0 else "odd"
// Rust if-expression (much cleaner than ternary)
let label = if x % 2 == 0 { "even" } else { "odd" };
# 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
// 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
}
};
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.
match is an exhaustive expressionPython'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 — 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 — 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.
if, match, loop, and even bare { } blocks all evaluate to a value. Python's if / for / while evaluate to nothing.
# 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 — 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
loop with a value-carrying breakShown above: break i hands a value out of the loop. Python needs while True plus a sentinel variable initialised to None before the loop.
# 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 — 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
}
}
}
if let, while let, and let ... elsePython's walrus operator (:=) binds a value but cannot destructure. Rust binds by pattern, in three control-flow shapes:
# 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")
// 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 |
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:
&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
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
// 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
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: 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: 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
}
Rc, Arc, RefCellEvery 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 — 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
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.
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.
let cell = RefCell::new(5);
let first = cell.borrow_mut();
// let second = cell.borrow_mut(); // panics: already mutably borrowed
nums = [1, 2, 3, 4, 5]
window = nums[1:4] # a NEW list — copies the elements
window[0] = 99 # original untouched
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.
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 |
nums = [1, 2, 3]
nums.append(4)
nums.pop()
print(nums[0])
print(len(nums))
nums2 = [x * 2 for x in nums] # list comprehension
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:
mixed = [1, "two", 3.0, [4], None] # completely normal
// 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:
# 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
// 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}"); }
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).
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
buf = [0, 0, 0, 0, 0]
buf.append(1) # now 6 — nothing stops you
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
IndexError.
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.
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
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.
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}")
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.
point = (3, 4)
x, y = point # destructuring
print(point[0])
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
(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.
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.
&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 |
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
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
&str in function parameters, return String when you built something new.
s = "hello"
s = str(42)
s = f"{a} {b}"
s = "".join(parts)
s = "ab" * 3
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:
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
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
| 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.98 — Result<String, _>; _lossy variant never fails |
b.decode("utf-16-be") |
String::from_utf16be(&b) |
New in 1.98 — Result<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() |
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
// 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();
Python's for c in s gives characters. Rust makes you say which unit you mean.
for c in s: ... # characters
for b in s.encode(): ... # bytes
for i, c in enumerate(s): ...
for line in s.splitlines(): ...
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().
s = "hello"
c = s[0] # 'h'
c = s[-1] # 'o'
sub = s[1:3] # 'el'
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.
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
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:
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)
// "é" 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.
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
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.
n = int("42")
f = float("3.14")
try:
n = int(user_input)
except ValueError:
n = 0
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:
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
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()?;
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 |
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"
// 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.)
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.
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
}
}
| 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 |
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)
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:
@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="")
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 |
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 — 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
...
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.
Defaultfrom 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
#[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
Python's _private is a naming convention — nothing stops access. Rust's default is private and the compiler refuses.
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
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
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.
Python's Enum holds simple variants. Rust enums hold data too, making them far more powerful.
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
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()
}
}
}
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:
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
// 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 |
This is the biggest conceptual shift. Python uses exceptions; Rust uses return values.
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}")
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:
# 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
// 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)
}
? 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 — just raise with a message
def process(path: str) -> str:
content = read_file(path)
if not content:
raise ValueError("empty file")
return content.upper()
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 checksdef 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")
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 |
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 — mechanicallyThe 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 |
panic! is an exception you have decided in advance never to catch. Its closest Python sibling is AssertionError, not ValueError.
# 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
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.
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 happenmatch is exhaustive, so sometimes you must write an arm you know is dead. unreachable!() documents that for both the compiler and the next reader.
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
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"),
};
unreachable!() is an admission that the types are looser than your logic.
todo!() — compiling incomplete codeThis 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 — a stub body needs no type to line up
def calculate_tax(order: Order) -> Decimal:
... # or: raise NotImplementedError
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.
.unwrap() calls.unwrap() appears throughout this document — that's brevity in examples, not a recommendation. It means "panic if this isn't Some/Ok."
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
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.
# 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:
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
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) |
Python gets conformance by duck typing or Protocol; Rust makes it explicit with traits.
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())
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(...)]:
@dataclass
class Point: # __eq__, __repr__ etc. auto-generated
x: float
y: float
#[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
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.
# Python: always dynamic. Every call is a runtime dict lookup on the instance.
def print_greeting(thing: Greetable) -> None:
print(thing.greet())
// 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.
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.
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
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.
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.
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.
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: monkeypatching is allowed, and it's global and invisible
import datetime
datetime.date.today = lambda: "lol" # every library in the process is affected
// 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 */ }
Send, Sync, Copy, SizedTraits 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 |
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)
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 |
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 |
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`
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:
def apply_twice(f, x): # Python doesn't care what f captures
return f(f(x))
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: 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: `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.
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 typev: 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
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.
# 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)
// 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 |
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.
Python 3.10+ has match. Rust's match is more powerful and exhaustive (the compiler forces you to handle every case).
command = ("move", 10, 20)
match command:
case ("move", x, y):
print(f"Move to {x},{y}")
case ("quit",):
print("Quit")
case _:
print("Unknown")
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:
x = 15
match x:
case n if n < 0:
print("negative")
case n if n < 10:
print("small")
case _:
print("big")
let x = 15;
match x {
n if n < 0 => println!("negative"),
n if n < 10 => println!("small"),
_ => println!("big"),
}
match Can't ExpressPython supports or-patterns (|) and capture patterns, but not range patterns or @ bindings.
# 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"
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 ❌ |
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: 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: 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:
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.
// '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:
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.
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:
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.
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 |
String instead of a &str.
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
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())
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):
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
use rayon::prelude::*;
let numbers: Vec<i64> = (0..1_000_000).collect();
let sum: i64 = numbers.par_iter().sum(); // parallel across all CPU cores
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 threadSync — this type can be referenced from multiple threads at onceThe compiler derives them automatically and refuses code that violates them.
# 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.
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();
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:
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
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 locklock = threading.Lock()
counter = 0
with lock:
counter += 1 # correct
counter += 1 # ALSO legal — the lock is advisory, nothing enforces it
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.
| 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() |
# 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)
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 |
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
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--------"
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:
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.
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}");
itoa crate's performance, now built into std — worth reaching for in hot loops instead of .to_string().
# Python: integers are arbitrary precision
x = 10 ** 100 # works fine
// 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
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.
x = 1 + 1.5 # Python auto-converts int to float
let x = 1_i32;
// let y = x + 1.5; // compile error: mismatched types
let y = x as f64 + 1.5; // explicit cast required
s = "hello"
c = s[0] # 'h'
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
a = [1, 2, 3]
b = a # both point to same list
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
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.
Three concrete reasons, all of them consequences of things Rust lacks.
A Rust fn takes a fixed number of parameters. Always. So println! cannot be a function:
# Python: *args is free
def log(fmt, *args):
print(fmt.format(*args))
log("{} + {} = {}", 1, 2, 3)
log("{}", 1)
println!("{}", 1);
println!("{} + {} = {}", 1, 2, 3); // same macro, different arity
Because a macro sees your code as syntax, it can check things before the program runs.
"{} {}".format(1) # IndexError — at runtime, in production
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.
Without inheritance, Rust would need you to hand-write Debug, Clone, PartialEq, serialization, and CLI parsing for every struct. #[derive(...)] generates them.
@dataclass # runtime: builds methods when the class is defined
class Point:
x: float
y: float
#[derive(Debug, Clone, PartialEq)] // compile time: generates impl blocks
struct Point { x: f64, y: f64 }
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.
| 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.
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.
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.
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.
$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 |
| 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:
No Python equivalent. No syntactic repetition operator. *args collects values at runtime; this matches tokens at compile time.
macro_rules! print_all {
( $( $item:expr ),* ) => {
$( // repeat this block per item
println!("{}", $item);
)*
};
}
print_all!("a", 1, true); // three println! calls
Rules are tried top to bottom, first match wins — so put specific cases first.
No Python equivalent. No dispatch on syntactic shape. A Python function has one signature and inspects its arguments at runtime.
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
}};
}
{{ ... }} 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.
Variables a macro introduces cannot collide with the caller's — a guarantee Python's exec-based tricks can never make.
No Python equivalent. No hygiene, because there is no expansion step. exec() shares the caller’s namespace and will happily clobber a local.
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.
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
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.
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.
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(): ...
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()
});
The genuinely load-bearing use case: implementing the same trait for many types.
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
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.
Derives are the macros you will use most, and you almost never write them. #[derive(X)] generates impl X for YourType.
| 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 |
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
#[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.
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 |
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
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.
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
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
Write it by hand when the generated behaviour is wrong:
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
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(***)")
}
}
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
#[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.
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.
// 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.
| 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:
# 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
// 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().
Macro errors are the worst error messages in Rust, because the compiler reports problems in code you never wrote.
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.
| 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 |
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.
No Python equivalent. Nothing to export. Python has no macros, so there is no separate namespace for them and no visibility rule to learn.
#[macro_export] // makes it available as crate::my_macro
macro_rules! my_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.
| 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 |
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.
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:
unsafe function (including all FFI)staticunsafe trait (Send / Sync by hand)union fieldimport 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
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."
# 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's FFI — declare the signature, call inside unsafe
unsafe extern "C" {
fn abs(input: i32) -> i32;
}
let n = unsafe { abs(-42) }; // 42
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.
This is how Vec, String, and RefCell are all built — a small audited unsafe core behind an interface that cannot be misused.
No Python equivalent. No aliasing rules to uphold, and no unsafe to scope. Two mutable views of one list are ordinary, unremarkable Python.
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 |
unsafe. It exists so that the standard library and hardware-facing crates can be written in Rust rather than in C.
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.
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
#[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
The rough equivalent of pip install package[extra], but the flags also gate code.
# pyproject.toml equivalent
[project.optional-dependencies]
postgres = ["asyncpg"]
# 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 }
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'
#[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.
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) |
#[cfg] is not compiled, so it's also not checked. It rots silently. CI should build the feature combinations you actually support.
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.
| 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 |
# 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"]
# 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
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 |
| 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 gaps run both ways. These are the Python conveniences you will miss, and what to reach for instead.
Rust has none of this. The workarounds:
def connect(host, port=5432, *, timeout=30, retries=3, **extra):
...
connect("db.local", timeout=5) # skip what you don't care about
// 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
| 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 |
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 |
| Concept | Python | Rust |
|---|---|---|
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 |
macro_rules! guide