PONY λ M2 Modula-2

Rust.CodeCompared.To/Python

An interactive executable cheatsheet comparing Rust and Python

Rust 1.97 Python 3.14
Hello World & Running
Hello, World
There is no entry point to declare and nothing to wrap the statement in. A Python file is executed top to bottom the moment it is handed to the interpreter, so a single expression is a whole program.
fn main() { println!("Hello, World!"); }
print("Hello, World!")
print is an ordinary function, not a macro — which is why the format string cannot be checked at compile time, and why print can be reassigned, wrapped, or passed as a value like anything else.
No compile step
The single biggest change. never_called below references a function that does not exist anywhere, and the program still runs to completion with a zero exit status — the name is not resolved until the line executes, and that line never does.
// Rust is compiled ahead of time. Nothing runs until // rustc has typechecked and borrow-checked every line: // // rustc --edition 2024 main.rs && ./main // cargo run # the everyday form // cargo build --release // cargo check # typecheck without codegen // // A misspelled method is a build failure, not an outage. fn main() { let message = "compiled, then run"; println!("{message}"); }
# Python is interpreted. There is no build artifact and # nothing is checked before the line actually executes: # # python3 script.py run a file # python3 -c '...' run a string # python3 start the REPL # python3 -m module run an installed module # # The bytecode cache in __pycache__/ is an optimization, # not a build step — deleting it changes nothing but speed. message = "parsed line by line, then run" print(message) def never_called(): this_function_does_not_exist() # never checked, never raises print("a call that would not compile in Rust sits here unnoticed")
This is why the Python ecosystem grew a second toolchain to replace the compiler you gave up: mypy or pyright for types, ruff for lints, and pytest for the coverage that has to reach every branch because nothing else will. Turn all three on the first day rather than the first outage.
The __main__ guard
Any Python file is simultaneously a script and a library, and importing one executes it. The __name__ check is how a file distinguishes "I was run" from "I was imported" — the split that Rust makes structurally with main.rs versus lib.rs.
// A binary crate has exactly one fn main; a library crate // has none. Which one you are is decided by the file's // place in the crate layout (src/main.rs vs src/lib.rs), // so the code itself never has to ask. fn greet(name: &str) -> String { format!("Hello, {name}!") } fn main() { println!("{}", greet("Ada")); }
def greet(name): return f"Hello, {name}!" # Every module runs top to bottom when imported, so a script # that is ALSO importable has to ask which it is right now: if __name__ == "__main__": print(greet("Ada")) else: print("imported as a library — the demo above did not run")
Forgetting the guard is a real bug, not a style nit: without it, importing the module fires off whatever the script does at the bottom — argument parsing, a server, a long computation. __name__ is "__main__" only in the file that was invoked.
The REPL
The REPL is not a toy here — it is the primary way Python developers explore an unfamiliar library, and it changes how the language is learned. dir(obj) and help(obj) answer at runtime the questions rustdoc answers ahead of time.
// Rust has no official REPL. The nearest equivalents are // the Playground, `cargo script` for single files, and the // evcxr Jupyter kernel — none of them shipped with rustc. // The everyday loop is edit, cargo check, cargo run. fn main() { let squares: Vec<i32> = (1..=5).map(|value| value * value).collect(); println!("{squares:?}"); }
# Typing `python3` drops you into a REPL where every # expression prints its repr. In 3.14 it is a full-screen # editor: colored output, multi-line editing, and history. # # >>> squares = [value * value for value in range(1, 6)] # >>> squares # [1, 4, 9, 16, 25] # >>> help(list.append) squares = [value * value for value in range(1, 6)] print(squares) # python3 -i script.py runs the script, then leaves you in # the REPL with every variable it defined still alive. print("run with -i to land in a REPL holding this state")
Because objects carry their type and docstrings with them, introspection replaces a lot of what documentation does in Rust. The 3.14 REPL also finally handles pasted blocks correctly, which older versions notoriously did not.
Debug output
Every object has a __repr__, so there is no #[derive(Debug)] to remember and no type that cannot be printed. @dataclass generates a useful one; a plain class falls back to <Measurement object at 0x…>, which is the Python equivalent of forgetting the derive.
#[derive(Debug)] struct Measurement { label: String, value: f64, } fn main() { let reading = Measurement { label: String::from("depth"), value: 12.5 }; println!("{reading:?}"); // Debug println!("{reading:#?}"); // pretty Debug let doubled = dbg!(reading.value * 2.0); println!("{doubled}"); }
from dataclasses import dataclass @dataclass class Measurement: label: str value: float reading = Measurement(label="depth", value=12.5) print(reading) # __repr__ — dataclass writes it for you print(repr(reading)) # the same string, explicitly import pprint pprint.pp(reading) # the pretty-Debug equivalent # The nearest thing to dbg! is f-string self-documentation: doubled = reading.value * 2 print(f"{doubled = }")
The f"{value = }" form prints both the expression text and its value, which is exactly what dbg! does minus the file and line. Unlike dbg!, it does not return its argument, so it cannot be dropped inline into an expression.
Variables & Dynamic Typing
No let, no mut
Assignment is the only binding form, every name is rebindable, and a name has no declared type — it is a label attached to whatever object was assigned last. Immutability, when you want it, belongs to the object (a tuple, a frozenset, a string), never to the name.
fn main() { let total = 10; // immutable binding let mut counter = 0; // mutable binding counter += 1; // total += 1; // error: cannot assign twice println!("{total} {counter}"); let label: &str = "fixed at compile time"; println!("{label}"); // label = 42; // error: mismatched types }
total = 10 # there is no binding keyword at all counter = 0 counter += 1 total += 1 # perfectly legal — nothing is immutable print(total, counter) label = "a string right now" label = 42 # and an int a line later print(label, type(label).__name__)
A Rust habit worth keeping: treat a name as single-assignment unless you have a reason, because a rebind that changes the type is invisible to every reader. Nothing in the language will help — the checkers only catch it if you annotate.
Type hints enforce nothing
Annotations look exactly like Rust's and behave like a comment the interpreter files away. Nothing validates them at run time, and in Python 3.14 (PEP 649) they are not even evaluated until something asks, so a hint naming a nonexistent type costs nothing until you look.
fn double(value: i32) -> i32 { value * 2 } fn main() { println!("{}", double(21)); // double("nope"); // error[E0308]: mismatched types // // the program does not build at all }
def double(value: int) -> int: return value * 2 print(double(21)) # 42 print(double("nope")) # "nopenope" — the hint is not a check # The annotation is only metadata; ask for it explicitly: import typing print(typing.get_type_hints(double))
The check has to be run separately — mypy . or pyright — and it is the closest thing to cargo check that Python has. Treat annotations as mandatory and the checker as part of CI, or they degrade into decoration that lies about the code.
Rebinding, not shadowing
Rust's shadowing creates a genuinely new binding, so the old one survives in the enclosing scope. Python has no such thing: the second assignment moves the existing name. The scoping unit is the function, not the block — if, for, and while bodies share the enclosing scope.
fn main() { let reading = "42"; let reading: i32 = reading.parse().unwrap(); // a NEW binding let reading = reading * 2; // and another println!("{reading}"); { let reading = "inner scope"; println!("{reading}"); } println!("{reading}"); // the outer binding is untouched }
reading = "42" reading = int(reading) # the same name, pointed somewhere else reading = reading * 2 print(reading) if True: reading = "inner block" # NOT a new scope — this overwrites print(reading) # 'inner block' def show(): reading = "function local" # functions DO make a new scope print(reading) show() print(reading)
The practical consequence is that a loop variable outlives its loop, and a name assigned inside an if may or may not exist afterward depending on whether the branch ran. Referencing it when the branch did not run raises NameError at that moment, not before.
Constants are a convention
There is no const and no compile-time evaluation. SCREAMING_SNAKE_CASE is a naming convention meaning "do not reassign this", and Final tells the static checker to enforce what the interpreter will not.
const MAX_RETRIES: u32 = 3; static GREETING: &str = "hello"; fn main() { println!("{MAX_RETRIES} {GREETING}"); // MAX_RETRIES = 4; // error: cannot assign to constant }
from typing import Final MAX_RETRIES: Final[int] = 3 GREETING = "hello" print(MAX_RETRIES, GREETING) MAX_RETRIES = 4 # runs fine; only a type checker complains print(MAX_RETRIES) # Real immutability belongs to the object, not the name: ALLOWED_MODES = frozenset({"read", "write"}) try: ALLOWED_MODES.add("execute") except AttributeError as error: print("frozenset is genuinely immutable:", error)
Because constants are ordinary module-level names, they are also mutable if the object they hold is — a module-level list "constant" can be appended to from anywhere. Reach for tuple and frozenset when you want the guarantee.
Truthiness
Every object answers bool(). Falsy means None, False, any zero number, and any empty container; everything else is truthy — including [0], "0", and "False".
fn main() { let items: Vec<i32> = Vec::new(); // if items { } // error: expected `bool`, found `Vec<i32>` if items.is_empty() { println!("empty"); } let count = 0; // if count { } // error: expected `bool`, found integer if count == 0 { println!("zero"); } }
items = [] if not items: # empty containers are falsy print("empty") count = 0 if not count: # 0, 0.0, and 0j are falsy print("zero") for value in [0, 1, "", "x", [], [0], None, {}]: print(f"{value!r:>6} -> {bool(value)}") # A class opts in by defining __bool__ or __len__: class Basket: def __init__(self, items): self.items = items def __len__(self): return len(self.items) print(bool(Basket([])), bool(Basket(["apple"])))
The trap for a Rust programmer is that if value: and if value is not None: are different tests, and the first one silently treats 0 and "" as missing. When a value could legitimately be empty or zero, always write the explicit is not None.
is vs ==
== calls __eq__ (Rust's PartialEq); is compares object identity, which is std::ptr::eq and nothing more. They are not interchangeable, and small integers and short strings are interned, so is sometimes accidentally agrees.
fn main() { let left = String::from("hello"); let right = String::from("hello"); println!("{}", left == right); // PartialEq: true println!("{}", std::ptr::eq(&left, &right)); // same address? false let alias = &left; println!("{}", std::ptr::eq(alias, &left)); // true }
left = "hello world!" right = "hello world!" print(left == right) # __eq__ — True print(left is right) # same object? implementation-defined numbers = [1, 2, 3] alias = numbers copy_of_numbers = list(numbers) print(numbers == copy_of_numbers, numbers is copy_of_numbers) # True False print(numbers == alias, numbers is alias) # True True value = None print(value is None) # the ONLY correct way to test for None
Use is only for the singletons None, True, and False. Using it on numbers or strings produces code that passes its tests and fails in production once a value arrives from a file instead of a literal.
Memory, Aliasing & Lifetimes
No ownership, no moves
Nothing is ever moved and nothing is ever consumed. Passing an object to a function hands over another reference to the same object, and CPython frees it when the last reference disappears — reference counting, with a cycle collector behind it.
fn consume(names: Vec<String>) -> usize { names.len() } fn main() { let names = vec![String::from("Ada"), String::from("Grace")]; let count = consume(names); // `names` is MOVED here println!("{count}"); // println!("{names:?}"); // error: value borrowed after move }
import sys def consume(names): return len(names) names = ["Ada", "Grace"] count = consume(names) # nothing moves; the callee gets the same object print(count) print(names) # still perfectly usable # Every object carries a reference count; CPython frees it at zero. print("references:", sys.getrefcount(names) - 1) also_names = names print("references:", sys.getrefcount(names) - 1)
Deallocation is therefore eager like Rust's, not deferred like a tracing GC — the object dies at the last del or rebind. What you lose is any guarantee about who else holds a reference, which is the entire subject of the next few rows.
Assignment always aliases
This is the row that catches every Rust programmer. There is no distinction between a move, a borrow, and a clone: b = a is always an alias, a function argument is always an alias, and any mutable object can be changed from any of its names, from anywhere, at any time.
fn main() { let mut original = vec![1, 2, 3]; let copied = original.clone(); // an explicit deep copy original.push(4); println!("{original:?} {copied:?}"); let borrowed = &original; // an explicit shared borrow println!("{borrowed:?}"); // original.push(5); // error: cannot borrow as mutable println!("{}", borrowed.len()); }
original = [1, 2, 3] alias = original # a second name for the SAME list alias.append(4) print(original) # [1, 2, 3, 4] — mutated through the other name def add_zero(items): items.append(0) # callers see this add_zero(original) print(original) # Immutable objects sidestep the whole question: text = "abc" def shout(value): value = value.upper() # rebinds the LOCAL name only return value print(shout(text), text)
There is no borrow checker to catch the resulting aliasing bug, and no &mut exclusivity to lean on. The discipline that replaces it is cultural: prefer immutable objects (tuple, frozenset, str), return new values instead of mutating arguments, and copy explicitly at API boundaries.
Mutable default arguments
A default argument is evaluated once, when the def statement runs, and the resulting object is reused by every call that omits the argument. A mutable default is therefore shared state hidden in the signature — the single most famous Python footgun.
// Rust has no default arguments, so this bug cannot be // written. The nearest shape is an Option parameter, and // unwrap_or_default builds a FRESH Vec on every call. fn append_item(item: i32, target: Option<Vec<i32>>) -> Vec<i32> { let mut items = target.unwrap_or_default(); items.push(item); items } fn main() { println!("{:?}", append_item(1, None)); println!("{:?}", append_item(2, None)); }
def append_item_buggy(item, target=[]): target.append(item) return target print(append_item_buggy(1)) # [1] print(append_item_buggy(2)) # [1, 2] — the SAME list, still there def append_item(item, target=None): if target is None: target = [] target.append(item) return target print(append_item(1)) # [1] print(append_item(2)) # [2] — correct
The fix is always the same: default to None and build the real value inside the body. ruff's B006 rule catches this, which is one more reason to install the lint toolchain on day one.
Shallow vs deep copies
Rust's Clone is deep by construction — cloning a Vec<Vec<i32>> clones the inner vectors too. Python's copies are shallow by default: list(x), x[:], x.copy(), and dict(x) all duplicate the container and share every element inside it.
fn main() { let grid = vec![vec![1, 2], vec![3, 4]]; let mut cloned = grid.clone(); // Clone is DEEP, all the way down cloned[0].push(99); println!("{grid:?}"); println!("{cloned:?}"); let point = (1, 2); // Copy types duplicate implicitly let moved_point = point; println!("{point:?} {moved_point:?}"); }
import copy grid = [[1, 2], [3, 4]] shallow = list(grid) # new outer list, SAME inner lists shallow[0].append(99) print(grid) # [[1, 2, 99], [3, 4]] — leaked through grid = [[1, 2], [3, 4]] deep = copy.deepcopy(grid) # a genuinely independent structure deep[0].append(99) print(grid, deep) # Slicing, list(), dict(), and .copy() are ALL shallow: print(grid[:] is grid, grid[:][0] is grid[0])
copy.deepcopy is the Clone equivalent, and it is deliberately not the default because it is expensive and can recurse forever without its cycle-tracking machinery. Reach for it consciously, and prefer nesting immutable objects so the question stops mattering.
No lifetimes
Lifetimes exist to prove a reference outlives nothing it points at. With reference counting that proof is unnecessary: an object stays alive exactly as long as some name, container, or closure still refers to it, so there is no annotation, no 'a, and no way to dangle.
// A reference cannot outlive what it points at, and the // signature has to say so when it is not obvious: fn longest<'a>(left: &'a str, right: &'a str) -> &'a str { if left.len() >= right.len() { left } else { right } } fn main() { let first = String::from("Ada Lovelace"); let second = String::from("Grace Hopper"); println!("{}", longest(&first, &second)); // A dangling reference is a compile error, always. }
def longest(left, right): return left if len(left) >= len(right) else right first = "Ada Lovelace" second = "Grace Hopper" print(longest(first, second)) # A returned object simply keeps its referent alive: def make_counter_holder(): shared = {"count": 0} def bump(): shared["count"] += 1 return shared["count"] return bump # `shared` outlives the call that created it bump = make_counter_holder() print(bump(), bump())
The cost is that liveness is now a runtime property you cannot see. An object you thought was finished stays alive because a cache, a logger, or a closure still holds it — the Python version of a leak, and the reason weakref and tracemalloc exist.
Reference cycles
The cycle that forces Rc/Weak in Rust is legal here and needs no ceremony, because reference counting is backed by a tracing cycle collector that finds unreachable loops. You can build a parent-child graph the obvious way and it is collected.
use std::cell::RefCell; use std::rc::{Rc, Weak}; struct Node { name: String, parent: RefCell<Weak<Node>>, // Weak breaks the cycle by hand children: RefCell<Vec<Rc<Node>>>, } fn main() { let parent = Rc::new(Node { name: String::from("root"), parent: RefCell::new(Weak::new()), children: RefCell::new(Vec::new()), }); let child = Rc::new(Node { name: String::from("leaf"), parent: RefCell::new(Rc::downgrade(&parent)), children: RefCell::new(Vec::new()), }); parent.children.borrow_mut().push(Rc::clone(&child)); println!("{} -> {}", parent.name, child.name); println!("strong count: {}", Rc::strong_count(&parent)); }
import gc class Node: def __init__(self, name): self.name = name self.parent = None self.children = [] parent = Node("root") child = Node("leaf") parent.children.append(child) child.parent = parent # a cycle — refcounts never reach zero print(parent.name, "->", parent.children[0].name) del parent, child collected = gc.collect() # the cycle detector cleans it up anyway print("objects reclaimed from cycles:", collected > 0) import weakref class WeakNode: def __init__(self, name, parent=None): self.name = name self._parent = weakref.ref(parent) if parent else None @property def parent(self): return self._parent() if self._parent else None root = WeakNode("root") print(WeakNode("leaf", root).parent.name)
weakref is still the right tool for caches and back-references, for the same reason Weak is in Rust: it keeps the collector's job cheap and makes the ownership direction explicit. An object with a __del__ in a cycle used to be uncollectable; since 3.4 it is not.
Drop vs __del__
__del__ looks like Drop and is not: it fires whenever the refcount happens to hit zero, may be skipped at interpreter shutdown, and swallows exceptions raised inside it. The real deterministic-cleanup construct is the context managerwith plus __enter__/__exit__.
struct Connection { name: String, } impl Drop for Connection { fn drop(&mut self) { println!("closing {}", self.name); } } fn main() { { let _connection = Connection { name: String::from("db") }; println!("working"); } // drop runs HERE, deterministically println!("after the scope"); }
class Connection: def __init__(self, name): self.name = name def __del__(self): # NOT a reliable Drop print("closing", self.name) def __enter__(self): return self def __exit__(self, exception_type, exception, traceback): print("released", self.name) return False with Connection("db") as connection: # this IS the Drop equivalent print("working with", connection.name) print("after the with block")
__exit__ runs on the normal path and on the exception path, which makes with the direct counterpart of a scope-bound Drop. Anything holding a file, socket, or lock should be used with with, never left to __del__.
Strings
One string type
The String/&str split disappears: there is one type, str, it is immutable, and it is a sequence of Unicode code points rather than UTF-8 bytes. No to_string(), no &, no Cow.
fn takes_slice(text: &str) -> usize { text.len() } fn main() { let owned: String = String::from("hello"); let slice: &str = "hello"; let borrowed: &str = &owned; println!("{} {} {}", takes_slice(slice), takes_slice(borrowed), owned.len()); let mut buffer = String::new(); buffer.push_str("grow"); buffer.push('!'); println!("{buffer}"); }
text = "hello" # str — that is the whole type system for text print(len(text)) def takes_text(value): return len(value) print(takes_text(text), takes_text("literal")) # str is immutable, so "growing" one produces a new object: buffer = "grow" buffer += "!" print(buffer) # Building in a loop uses a list and one join, not repeated +=: parts = [f"line {index}" for index in range(3)] print(", ".join(parts))
Immutability means += in a loop allocates a new string every iteration — the String::push_str equivalent is collecting into a list and calling "".join(...) once. That idiom is worth internalizing early; it is the one string performance rule Python actually cares about.
f-strings
The mini-language inside the braces is nearly identical to Rust's — :>10, :^6, :.2f all mean the same thing. The difference is that f-strings interpolate arbitrary expressions, not just names, and are formatted at run time.
fn main() { let name = "Ada"; let score = 91.5; println!("{name} scored {score}"); println!("{name} scored {score:.2}"); println!("{:>10} | {:<8} | {:^6}", name, "middle", 42); let message = format!("{name}: {:.1}%", score); println!("{message}"); }
name = "Ada" score = 91.5 print(f"{name} scored {score}") print(f"{name} scored {score:.2f}") print(f"{name:>10} | {'middle':<8} | {42:^6}") message = f"{name}: {score:.1f}%" print(message) # Any expression is allowed inside the braces: scores = [91.5, 78.0, 88.25] print(f"average {sum(scores) / len(scores):.2f} over {len(scores)} runs")
Note :.2f where Rust writes :.2: Python's presentation type is explicit, and omitting the f gives you significant digits instead of decimal places. Since 3.12 f-strings may also nest the same quote character and span multiple lines.
t-strings (3.14)
New in 3.14 (PEP 750). A t"..." literal evaluates to a Template object that keeps the literal fragments and the interpolated values separate — it never becomes a string on its own, so a library can escape, parameterize, or reject each value before assembling anything.
// The closest Rust equivalent is a macro that captures the // template and its interpolations separately, so the two can // be processed before they are ever concatenated — which is // how sqlx's query! and similar macros keep values out of // the statement text. There is no built-in string form. fn main() { let user_input = "Robert'); DROP TABLE students;--"; let statement = "SELECT * FROM students WHERE name = ?"; println!("{statement} with parameter {user_input:?}"); }
from string.templatelib import Template user_input = "Robert'); DROP TABLE students;--" template = t"SELECT * FROM students WHERE name = {user_input}" print(type(template).__name__) # Template, NOT str for part in template.strings: print("literal:", repr(part)) for interpolation in template.interpolations: print("value:", repr(interpolation.value), "from", interpolation.expression)
This is Python's answer to the problem format! cannot solve: an f-string has already lost the boundary between text and data by the time a function receives it. Expect HTML and SQL libraries to accept templates where they currently warn you never to pass an f-string.
Indexing and slicing
Python indexes strings by code point, so text[0] is legal, cheap, and returns another str of length one — there is no char type. Negative indices count backward, and a slice never panics on a boundary because there are no byte boundaries to hit.
fn main() { let text = "héllo"; // println!("{}", text[0]); // error: `str` cannot be indexed println!("{}", &text[0..1]); // BYTE range; panics on a boundary println!("{:?}", text.chars().nth(1)); println!("{} bytes, {} chars", text.len(), text.chars().count()); let reversed: String = text.chars().rev().collect(); println!("{reversed}"); }
text = "héllo" print(text[0]) # 'h' — indexing yields a 1-character str print(text[1]) # 'é' — code points, not bytes print(text[1:3]) # slicing works the same way print(text[-1]) # negative indices count from the end print(len(text)) # 5 code points, not 6 bytes print(text[::-1]) # the whole reverse idiom print(text.encode("utf-8"), len(text.encode("utf-8")))
The trade is that Rust's honesty about grapheme clusters is gone: len() counts code points, so an emoji with a skin-tone modifier still counts as more than one "character" and slicing can split it. For user-facing text segmentation you still want a library.
bytes vs str
This is the one place where Rust intuition transfers cleanly. bytes is Vec<u8>, str is validated text, and .encode()/.decode() are the explicit conversions — Python 3 refuses to mix them, exactly as Rust does.
fn main() { let text = String::from("naïve"); let bytes: &[u8] = text.as_bytes(); println!("{bytes:?}"); let decoded = String::from_utf8(bytes.to_vec()).unwrap(); println!("{decoded}"); let raw = vec![0xff, 0xfe]; match String::from_utf8(raw) { Ok(value) => println!("{value}"), Err(error) => println!("not UTF-8: {error}"), } }
text = "naïve" raw = text.encode("utf-8") # bytes — the closest thing to Vec<u8> print(raw, len(raw)) decoded = raw.decode("utf-8") # bytes -> str print(decoded) try: bytes([0xff, 0xfe]).decode("utf-8") except UnicodeDecodeError as error: print("not UTF-8:", error.reason) # The two types never mix implicitly: try: print(text + raw) except TypeError as error: print("TypeError:", error)
File and socket APIs hand back bytes unless you open them in text mode, and bytes literals are written b"...". Decoding errors take a policy argument (errors="replace", "ignore", "surrogateescape") where Rust makes you choose between from_utf8 and from_utf8_lossy.
Splitting and joining
The method names line up almost one to one, with two surprises: join is a method on the separator (", ".join(items)), and split returns an eager list rather than a lazy iterator.
fn main() { let record = " ada,grace,radia "; let trimmed = record.trim(); let names: Vec<&str> = trimmed.split(',').collect(); println!("{names:?}"); let joined = names.join(" & "); println!("{joined}"); println!("{}", trimmed.replace(',', ";")); println!("{}", trimmed.to_uppercase()); println!("{}", trimmed.starts_with("ada")); println!("{:?}", trimmed.find("grace")); }
record = " ada,grace,radia " trimmed = record.strip() names = trimmed.split(",") print(names) print(" & ".join(names)) # the separator owns join, not the list print(trimmed.replace(",", ";")) print(trimmed.upper()) print(trimmed.startswith("ada")) print(trimmed.find("grace")) # -1 when absent, not an Option print(trimmed.removeprefix("ada,"))
Watch find: it returns -1 for "not found" instead of an Option, so a bare if text.find(x): is wrong twice over — -1 is truthy and 0 is falsy. Use in for membership and index when a miss should raise.
Numbers
One unbounded int
There is exactly one integer type and it is unbounded. No u8, i32, usize, no as casts, no checked_add, and no overflow — an int grows until memory runs out.
fn main() { let small: u8 = 255; let widened: i64 = small as i64 + 1; println!("{widened}"); // Overflow is a panic in debug and a wrap in release, // so the choice has to be explicit: println!("{:?}", small.checked_add(1)); println!("{}", small.wrapping_add(1)); println!("{}", small.saturating_add(1)); println!("{}", u64::MAX); }
small = 255 print(small + 1) # 256 — no width to overflow huge = 2 ** 200 print(huge) print(huge * huge % 1_000_003) import sys print("machine word size:", sys.int_info.bits_per_digit, "bit digits") # Fixed-width arithmetic is opt-in, via masking or a library: print((small + 1) & 0xFF)
That removes an entire class of bug and adds a performance cliff: big-int arithmetic is far slower than a machine word, and it is why numeric code reaches for numpy, whose arrays do have int32/float64 dtypes and do wrap on overflow.
Division and modulo
Two differences bite. / on two ints produces a float7 / 2 is 3.5, and the integer form is the separate // operator. And // floors where Rust truncates, so the results diverge for negative operands.
fn main() { println!("{}", 7 / 2); // 3 — integer division truncates println!("{}", 7.0 / 2.0); // 3.5 println!("{}", 7 % 3); // 1 println!("{}", -7 / 2); // -3 — truncates toward zero println!("{}", -7 % 3); // -1 — sign follows the dividend println!("{}", (-7i32).rem_euclid(3)); // 2 }
print(7 / 2) # 3.5 — / is ALWAYS float division print(7 // 2) # 3 — // is floor division print(7 % 3) # 1 print(-7 // 2) # -4 — floors, it does not truncate print(-7 % 3) # 2 — sign follows the DIVISOR print(divmod(-7, 3)) print(7 / 2 == 3.5, type(7 // 2).__name__)
The modulo sign rule follows from that: Python's % takes the sign of the divisor, matching rem_euclid for positive divisors rather than Rust's %. Any ported hashing, indexing, or wrap-around arithmetic needs checking against negative inputs.
Conversions and promotion
Mixed arithmetic promotes automatically — int + float is a float, with no as and no From impl. The conversion functions int(), float(), str() are constructors that raise on bad input rather than returning a Result.
fn main() { let count: i32 = 7; let ratio: f64 = count as f64 / 2.0; // `as` is required println!("{ratio}"); let parsed: i32 = "42".parse().unwrap(); println!("{}", parsed + count); let truncated = 3.99_f64 as i32; // toward zero println!("{truncated}"); println!("{}", 3.99_f64.round() as i32); }
count = 7 ratio = count / 2 # int and float mix freely; int is promoted print(ratio, type(ratio).__name__) parsed = int("42") # raises ValueError on bad input print(parsed + count) print(int(3.99)) # truncates toward zero, like `as` print(round(3.5), round(2.5)) # banker's rounding: 4 and 2 print(float(7), str(7), bool(0))
One genuine surprise: round uses banker's rounding (ties to even), so round(2.5) is 2, not 3. Rust's f64::round rounds half away from zero, so any ported financial or reporting code will disagree at exactly the boundary.
Exact arithmetic
float is f64 and behaves identically, so the classic 0.1 + 0.2 result is the same. The difference is that exact decimal and rational arithmetic are in the standard library — decimal and fractions — instead of requiring a crate.
fn main() { println!("{}", 0.1_f64 + 0.2_f64); // 0.30000000000000004 println!("{}", (0.1_f64 + 0.2_f64) == 0.3); // Exact decimal or rational arithmetic needs a crate: // rust_decimal::Decimal, or num_rational::Ratio. let cents: i64 = 10 + 20; // the integer-cents workaround println!("{}.{:02}", cents / 100, cents % 100); }
print(0.1 + 0.2) # the same IEEE 754 answer print(0.1 + 0.2 == 0.3) from decimal import Decimal print(Decimal("0.1") + Decimal("0.2")) print(Decimal("0.1") + Decimal("0.2") == Decimal("0.3")) from fractions import Fraction print(Fraction(1, 3) + Fraction(1, 6)) import math print(math.isclose(0.1 + 0.2, 0.3))
Decimal is the right type for money and has a configurable context for precision and rounding mode. Note Decimal("0.1") takes a string: passing the float 0.1 hands it the already-inexact binary value, which defeats the point.
Collections
list is an untyped Vec
A list is a growable array of references, so it has no element type and can hold anything — the Vec<Box<dyn Any>> case is the default rather than the escape hatch. The method names mostly rhyme with Vec.
fn main() { let mut numbers: Vec<i32> = vec![3, 1, 2]; numbers.push(4); numbers.sort(); println!("{numbers:?} len={} first={:?}", numbers.len(), numbers.first()); // numbers.push("four"); // error: mismatched types // Mixing needs an enum or Box<dyn Any>. let mixed: Vec<Box<dyn std::fmt::Debug>> = vec![Box::new(1), Box::new("two"), Box::new(3.0)]; println!("{mixed:?}"); }
numbers = [3, 1, 2] numbers.append(4) numbers.sort() print(numbers, len(numbers), numbers[0]) mixed = [1, "two", 3.0, None, [5]] # no element type at all print(mixed) numbers.extend([7, 8]) # Vec::extend numbers.insert(0, 0) # Vec::insert print(numbers.pop(), numbers) # pops from the END, like Vec::pop print(numbers.index(3), 3 in numbers)
Because elements are references, a list of a million integers is a million pointers to boxed objects, not a packed buffer. When the layout matters — numerics, images, columnar data — the answer is array, numpy, or polars, all of which store unboxed values.
Slices copy
Python slices copy. &numbers[1..4] is a borrow with no allocation; numbers[1:4] builds a new list, so mutating it cannot affect the original — the opposite trade from Rust, and the reason slicing in a hot loop is a performance problem.
fn main() { let numbers = vec![0, 1, 2, 3, 4, 5]; let window: &[i32] = &numbers[1..4]; // a VIEW, no allocation println!("{window:?}"); println!("{:?}", &numbers[..2]); println!("{:?}", &numbers[4..]); let every_other: Vec<i32> = numbers.iter().step_by(2).copied().collect(); println!("{every_other:?}"); }
numbers = [0, 1, 2, 3, 4, 5] window = numbers[1:4] # a NEW list — slicing copies print(window) print(numbers[:2], numbers[4:], numbers[-2:]) print(numbers[::2]) # start:stop:step print(numbers[::-1]) # reversed copy window.append(99) # the original is untouched print(numbers) # A non-copying view exists, but you have to ask: view = memoryview(bytearray(b"abcdef")) print(bytes(view[1:4]))
The extended start:stop:step form has no Rust equivalent and absorbs step_by, rev, and take into one expression. Out-of-range slice bounds clamp instead of panicking, so numbers[2:99] is fine while numbers[99] raises.
dict, and no entry API
A dict is a HashMap that preserves insertion order (guaranteed since 3.7) and needs no type parameters. There is no entry API; its jobs are split between get(key, default), setdefault, and collections.defaultdict.
use std::collections::HashMap; fn main() { let mut ages: HashMap<&str, u32> = HashMap::new(); ages.insert("ada", 36); // The entry API is how you get "insert if absent": *ages.entry("grace").or_insert(0) += 85; *ages.entry("ada").or_insert(0) += 1; println!("{:?}", ages.get("ada")); // Option<&u32> println!("{}", ages.get("nobody").copied().unwrap_or(0)); let mut pairs: Vec<_> = ages.iter().collect(); pairs.sort(); println!("{pairs:?}"); }
ages = {"ada": 36} ages["grace"] = 85 # insert or overwrite print(ages["ada"]) # raises KeyError if absent print(ages.get("nobody")) # None instead of an exception print(ages.get("nobody", 0)) # ...or a default ages["ada"] = ages.get("ada", 0) + 1 # the or_insert idiom ages.setdefault("radia", 0) # the other one print(dict(sorted(ages.items()))) from collections import defaultdict tally = defaultdict(int) for word in "the cat the hat".split(): tally[word] += 1 # missing keys spring into existence print(dict(tally))
Subscripting a missing key raises KeyError where HashMap::get returns None — that difference is deliberate and idiomatic, because "the key must be there" is best expressed by letting it raise. Keys must be hashable, which in practice means immutable.
Sets
Sets have literal syntax and operator forms for every algebraic operation — &, |, -, ^, and <= for subset — which is considerably terser than HashSet's method chains plus collect.
use std::collections::HashSet; fn main() { let left: HashSet<i32> = [1, 2, 3, 4].into_iter().collect(); let right: HashSet<i32> = [3, 4, 5].into_iter().collect(); let mut intersection: Vec<_> = left.intersection(&right).copied().collect(); intersection.sort(); println!("{intersection:?}"); let mut union: Vec<_> = left.union(&right).copied().collect(); union.sort(); println!("{union:?}"); println!("{}", left.contains(&2)); }
left = {1, 2, 3, 4} right = {3, 4, 5} print(sorted(left & right)) # intersection print(sorted(left | right)) # union print(sorted(left - right)) # difference print(sorted(left ^ right)) # symmetric difference print(2 in left, left <= {1, 2, 3, 4, 5}) frozen = frozenset(left) # hashable, immutable, usable as a dict key print({frozen: "a set as a key"}[frozenset({1, 2, 3, 4})]) print(len({value % 3 for value in range(10)}))
One syntax trap: {} is an empty dict, not an empty set; the empty set is set(). frozenset is the immutable, hashable variant, which is what you need when a set has to be a dict key or a member of another set.
Tuples and unpacking
Tuples are immutable, heterogeneous, and indexed with [0] rather than .0 — so unlike Rust, the index has to be a literal the checker can see if it is going to know the element type. Destructuring goes further: *rest absorbs any number of elements.
fn min_max(values: &[i32]) -> (i32, i32) { let mut smallest = values[0]; let mut largest = values[0]; for &value in values { if value < smallest { smallest = value; } if value > largest { largest = value; } } (smallest, largest) } fn main() { let (smallest, largest) = min_max(&[3, 9, 1, 7]); println!("{smallest} {largest}"); let point = (1, 2, 3); println!("{} {}", point.0, point.2); }
def min_max(values): return min(values), max(values) # a tuple, without the parentheses smallest, largest = min_max([3, 9, 1, 7]) print(smallest, largest) point = (1, 2, 3) print(point[0], point[2]) # indexed, not .0/.2 first, *rest = [10, 20, 30, 40] # star unpacking print(first, rest) head, *middle, tail = [1, 2, 3, 4, 5] print(head, middle, tail) left, right = 1, 2 left, right = right, left # the swap idiom print(left, right)
Because a bare comma builds a tuple, return a, b is the multiple-return idiom and a, b = b, a swaps without a temporary. Watch for the accidental one-tuple: value = 1, is a tuple, and it is a real bug source.
Sorting
Sorting takes a key function rather than a comparator, and the key is called once per element — the decorate-sort-undecorate pattern, built in. There is no Ord to implement and no total-order proof; sorting mixed types just raises TypeError.
fn main() { let mut names = vec!["Grace", "ada", "Radia"]; names.sort(); // in place, by Ord println!("{names:?}"); names.sort_by_key(|name| name.to_lowercase()); println!("{names:?}"); let mut scores = vec![(90, "ada"), (75, "grace"), (90, "radia")]; scores.sort_by(|left, right| right.0.cmp(&left.0).then(left.1.cmp(right.1))); println!("{scores:?}"); }
names = ["Grace", "ada", "Radia"] names.sort() # in place print(names) print(sorted(names, key=str.lower)) # a new list, custom key print(sorted(names, key=len, reverse=True)) scores = [(90, "ada"), (75, "grace"), (90, "radia")] print(sorted(scores, key=lambda pair: (-pair[0], pair[1]))) from operator import itemgetter print(max(scores, key=itemgetter(0)))
A tuple key gives multi-level sorting, and negating a numeric field reverses just that level — key=lambda pair: (-pair[0], pair[1]) is the sort_by chain above in one expression. Python's sort is stable, so successive sorts compose.
Specialized containers
Most of std::collections has a counterpart: VecDeque is deque, BinaryHeap is the heapq functions operating on a plain list (min-heap, not max), and Counter replaces the whole entry-API tally loop.
use std::collections::{BTreeMap, VecDeque, BinaryHeap, HashMap}; fn main() { let mut queue: VecDeque<i32> = VecDeque::new(); queue.push_back(1); queue.push_front(0); println!("{:?} {:?}", queue.pop_front(), queue); let mut heap = BinaryHeap::from(vec![3, 1, 4]); println!("{:?}", heap.pop()); let ordered: BTreeMap<&str, i32> = [("b", 2), ("a", 1)].into_iter().collect(); println!("{ordered:?}"); let mut counts: HashMap<char, usize> = HashMap::new(); for character in "hello".chars() { *counts.entry(character).or_insert(0) += 1; } println!("{}", counts[&'l']); }
from collections import deque, Counter, namedtuple import heapq queue = deque([1]) queue.appendleft(0) # VecDeque, with O(1) at both ends print(queue.popleft(), list(queue)) heap = [3, 1, 4] heapq.heapify(heap) # a MIN-heap on a plain list print(heapq.heappop(heap)) counts = Counter("hello") # the entry().or_insert(0) loop, built in print(counts["l"], counts.most_common(2)) Point = namedtuple("Point", ["x", "y"]) print(Point(1, 2).x) # There is no BTreeMap; dict keeps insertion order, and you # sort when you need ordering: print(dict(sorted({"b": 2, "a": 1}.items())))
The notable absence is a sorted map — there is no BTreeMap in the standard library, because dict preserves insertion order and sorting on demand covers most uses. When you genuinely need ordered lookups, sortedcontainers is the ecosystem answer.
Control Flow
if is a statement
Python separates statements from expressions strictly. if/elif/else is a statement and evaluates to nothing, so the Rust habit of assigning from an if becomes either an assignment in each branch or the ternary X if condition else Y.
fn main() { let score = 85; let grade = if score >= 90 { "A" } else if score >= 80 { "B" } else { "C" }; println!("{grade}"); let doubled = { let base = score * 2; base }; // blocks are expressions println!("{doubled}"); }
score = 85 if score >= 90: grade = "A" elif score >= 80: grade = "B" else: grade = "C" print(grade) # The conditional EXPRESSION exists, but only in this one shape: label = "high" if score >= 80 else "low" print(label) # There is no block expression; a helper function is the workaround: def compute_doubled(value): base = value * 2 return base print(compute_doubled(score))
Blocks are not expressions either — no { ... } value, no implicit tail return, and loop cannot produce a value with break. When you want an expression-shaped computation, a small function or a comprehension is the idiom.
Loops and ranges
range is half-open exactly like 0..n, takes an optional step, and is lazy. There is no loop keyword (while True is the idiom), no labeled break, and break never carries a value.
fn main() { for index in 0..3 { println!("{index}"); } for index in (0..10).step_by(3) { print!("{index} "); } println!(); let mut countdown = 3; while countdown > 0 { countdown -= 1; } let mut total = 0; let found = loop { total += 7; if total > 20 { break total; } // loop yields a value }; println!("{found}"); }
for index in range(3): print(index) print(*range(0, 10, 3)) # start, stop, step — half-open like Rust countdown = 3 while countdown > 0: countdown -= 1 # There is no `loop`, and break carries no value: total = 0 while True: total += 7 if total > 20: break print(total) for index in range(5): if index == 1: continue if index == 3: break print("visiting", index)
Missing labeled breaks is the one real loss: escaping nested loops means a flag, a sentinel, or extracting the loops into a function and returning. There is no inclusive range operator either — 1..=5 is written range(1, 6).
for/else
A loop may carry an else clause, which runs when the loop finished without executing break. It has no Rust counterpart, and it replaces the "did I find it?" flag or sentinel Option that the search loop otherwise needs.
fn main() { let numbers = vec![4, 6, 8]; let mut found = None; for &value in &numbers { if value % 2 == 1 { found = Some(value); break; } } match found { Some(value) => println!("found odd: {value}"), None => println!("no odd number present"), } }
numbers = [4, 6, 8] for value in numbers: if value % 2 == 1: print("found odd:", value) break else: print("no odd number present") # runs only if no break happened for value in [4, 7, 8]: if value % 2 == 1: print("found odd:", value) break else: print("unreachable here")
Read else here as "no break" — the keyword choice is widely regarded as a mistake, and it is why the construct is less common than it deserves. while/else exists too, with the same rule.
The walrus operator
:= assigns inside an expression, which is the closest Python gets to if let. It cannot destructure and it does not narrow a type, so the test after it is still yours to write — usually is not None.
fn main() { let readings = vec!["12", "not-a-number", "7"]; for text in &readings { // if-let binds inside the condition: if let Ok(value) = text.parse::<i32>() { println!("parsed {value}"); } else { println!("skipping {text}"); } } // let-else handles the early-exit shape: let Some(first) = readings.first() else { return; }; println!("first: {first}"); }
readings = ["12", "not-a-number", "7"] def parse_int(text): return int(text) if text.lstrip("-").isdigit() else None for text in readings: if (value := parse_int(text)) is not None: # := binds in the condition print("parsed", value) else: print("skipping", text) # The same trick avoids computing a value twice: lengths = [length for text in readings if (length := len(text)) > 3] print(lengths)
There is no let-else equivalent, so the early-exit shape is an ordinary if ...: return. The walrus earns its keep most in comprehensions, where it is the only way to bind an intermediate value without computing it twice.
Iterators & Comprehensions
Comprehensions
A comprehension is the whole iter().filter().map().collect() pipeline in one expression, with the output type chosen by the brackets: [] for a list, {k: v} for a dict, {v} for a set. Nested for clauses read left to right, outermost first.
fn main() { let numbers = vec![1, 2, 3, 4, 5, 6]; let squares_of_evens: Vec<i32> = numbers .iter() .filter(|value| *value % 2 == 0) .map(|value| value * value) .collect(); println!("{squares_of_evens:?}"); let lookup: std::collections::HashMap<i32, i32> = numbers.iter().map(|value| (*value, value * value)).collect(); println!("{}", lookup[&3]); }
numbers = [1, 2, 3, 4, 5, 6] squares_of_evens = [value * value for value in numbers if value % 2 == 0] print(squares_of_evens) lookup = {value: value * value for value in numbers} # dict comprehension print(lookup[3]) remainders = {value % 3 for value in numbers} # set comprehension print(sorted(remainders)) pairs = [(row, column) for row in range(2) for column in range(2)] print(pairs)
This is the idiom Python reaches for where Rust reaches for adapters, and it is more readable up to about two clauses — past that, a loop or a generator function wins. Note there is no collect() turbofish problem, because the syntax already says which container you meant.
Generators
A function containing yield returns a lazy iterator, and the local variables are its state — no struct, no impl Iterator, no explicit next. Execution suspends at each yield and resumes there on the following call.
struct Fibonacci { current: u64, next: u64, } impl Iterator for Fibonacci { type Item = u64; fn next(&mut self) -> Option<u64> { let value = self.current; self.current = self.next; self.next = value + self.next; Some(value) } } fn main() { let first_ten: Vec<u64> = Fibonacci { current: 0, next: 1 }.take(10).collect(); println!("{first_ten:?}"); }
def fibonacci(): current, following = 0, 1 while True: # an infinite generator, lazily consumed yield current current, following = following, current + following import itertools print(list(itertools.islice(fibonacci(), 10))) # Any function containing `yield` becomes a generator function: def read_lines(text): for line in text.splitlines(): if line.strip(): yield line.strip().upper() print(list(read_lines("ada\n\ngrace\n")))
This is the feature Rust is still working toward with gen blocks. The cost is that the generator is a stateful object: iterating it twice yields nothing the second time, which surprises anyone expecting a reusable Iterator.
Lazy adapters
enumerate and zip are builtins; the rest of the adapter vocabulary lives in itertools as free functions taking the iterable first, rather than as methods chained onto it. All of them are lazy, like Rust's.
fn main() { let names = vec!["ada", "grace", "radia"]; for (index, name) in names.iter().enumerate() { println!("{index}: {name}"); } let scores = vec![91, 78, 88]; let zipped: Vec<_> = names.iter().zip(scores.iter()).collect(); println!("{zipped:?}"); let chained: Vec<_> = names.iter().chain(["hedy"].iter()).collect(); println!("{chained:?}"); let taken: Vec<i32> = (1..).take_while(|value| *value < 5).collect(); println!("{taken:?}"); }
import itertools names = ["ada", "grace", "radia"] for index, name in enumerate(names): print(f"{index}: {name}") scores = [91, 78, 88] print(list(zip(names, scores))) # stops at the shortest print(list(zip(names, scores, strict=True))) # 3.10+: raises on a mismatch print(list(itertools.chain(names, ["hedy"]))) print(list(itertools.takewhile(lambda value: value < 5, itertools.count(1)))) print(list(itertools.batched(range(7), 3))) # 3.12+: chunks
The reading order inverts — takewhile(f, count(1)) instead of (1..).take_while(f) — which is the main ergonomic cost of functions over methods. zip(..., strict=True) is worth knowing: the silent truncation on unequal lengths is a classic bug.
Reducing
The terminal operations are builtins that accept any iterable, and a bare generator expression may be passed without extra parentheses — any(value > 3 for value in numbers) allocates nothing, exactly like the Rust chain.
fn main() { let numbers = vec![1, 2, 3, 4]; println!("{}", numbers.iter().sum::<i32>()); println!("{:?}", numbers.iter().max()); println!("{}", numbers.iter().any(|value| *value > 3)); println!("{}", numbers.iter().all(|value| *value > 0)); let product = numbers.iter().fold(1, |accumulator, value| accumulator * value); println!("{product}"); let position = numbers.iter().position(|value| *value == 3); println!("{position:?}"); }
numbers = [1, 2, 3, 4] print(sum(numbers)) print(max(numbers), min(numbers)) print(any(value > 3 for value in numbers)) print(all(value > 0 for value in numbers)) import functools, operator print(functools.reduce(operator.mul, numbers, 1)) # fold print(math_total := sum(value * value for value in numbers)) print(numbers.index(3)) # raises ValueError if absent print(list(itertools.accumulate(numbers)) if (itertools := __import__("itertools")) else None)
fold is functools.reduce, and it is deliberately unfashionable: Guido wanted an explicit loop or a comprehension instead, so most reductions you will read use sum, max, or a loop rather than reduce.
The iteration protocol
The protocol is __iter__ plus __next__, and exhaustion is signaled by raising StopIteration rather than returning None — an exception used as ordinary control flow, which is thoroughly idiomatic here.
struct Countdown { remaining: u32, } impl Iterator for Countdown { type Item = u32; fn next(&mut self) -> Option<u32> { if self.remaining == 0 { None } else { self.remaining -= 1; Some(self.remaining + 1) } } } fn main() { let values: Vec<u32> = Countdown { remaining: 3 }.collect(); println!("{values:?}"); }
class Countdown: def __init__(self, start): self.remaining = start def __iter__(self): return self def __next__(self): if self.remaining == 0: raise StopIteration # the None of this protocol self.remaining -= 1 return self.remaining + 1 print(list(Countdown(3))) # The manual form, which `for` performs for you: iterator = iter([10, 20]) print(next(iterator), next(iterator), next(iterator, "exhausted"))
Writing the class by hand is rare; a generator function does the same job in three lines. It matters mostly when reading library code, and when you need an object that is both a container and iterable — in which case __iter__ should return a fresh iterator, not self.
Functions
Defining functions
There is no tail-expression return: falling off the end returns None, which is a genuine object rather than (). Forgetting return therefore produces a function that silently yields None instead of a compile error.
fn area(width: f64, height: f64) -> f64 { width * height // tail expression, no `return` } fn log_message(text: &str) { println!("{text}"); // returns () } fn main() { println!("{}", area(3.0, 4.0)); log_message("done"); }
def area(width: float, height: float) -> float: return width * height # `return` is mandatory def log_message(text: str) -> None: print(text) # implicitly returns None print(area(3, 4)) log_message("done") print(log_message("again")) # None — the () equivalent, but a real value # Functions are objects, with attributes: print(area.__name__, area.__annotations__)
Functions are first-class objects with a __name__, a docstring, and mutable attributes — the basis for decorators later on. A missing return is one of the errors a type checker catches for free once you annotate the return type.
Default and keyword arguments
Every parameter can have a default and every argument can be passed by name, which removes the builder pattern and the options struct entirely. Placing a bare * in the signature makes the following parameters keyword-only, so callers cannot pass them positionally.
// Rust has neither, so the options are a builder, an // options struct with Default, or several named functions: #[derive(Debug)] struct ConnectOptions { host: String, port: u16, timeout_seconds: u32, } impl Default for ConnectOptions { fn default() -> Self { ConnectOptions { host: String::from("localhost"), port: 5432, timeout_seconds: 30, } } } fn main() { let options = ConnectOptions { port: 6432, ..Default::default() }; println!("{options:?}"); }
def connect(host="localhost", port=5432, timeout_seconds=30): return f"{host}:{port} timeout={timeout_seconds}" print(connect()) print(connect(port=6432)) # named, order-independent print(connect(timeout_seconds=5, host="db")) # * forces everything after it to be keyword-only: def render(text, *, uppercase=False, width=10): value = text.upper() if uppercase else text return value.rjust(width) print(repr(render("ada", uppercase=True)))
Keyword-only parameters are the tool for boolean flags, where a bare True at a call site tells the reader nothing. Remember the earlier rule: a default must be immutable, or it is shared across every call.
*args and **kwargs
*values collects extra positional arguments into a tuple and **attributes collects keyword arguments into a dict; at a call site the same operators unpack a sequence or mapping back into arguments. No macro, and no requirement that the values share a type.
// Variadics require a macro. This is roughly what one // expands to — a slice of a common type: fn total(values: &[i32]) -> i32 { values.iter().sum() } macro_rules! total { ($($value:expr),*) => { total(&[$($value),*]) }; } fn main() { println!("{}", total!(1, 2, 3)); println!("{}", total(&[1, 2, 3, 4])); }
def total(*values): # any number of positional arguments return sum(values) # `values` is a tuple print(total(1, 2, 3), total(1, 2, 3, 4)) def describe(**attributes): # any number of keyword arguments return ", ".join(f"{name}={value}" for name, value in attributes.items()) print(describe(host="db", port=5432)) def forward(*args, **kwargs): # the universal wrapper signature return total(*args), describe(**kwargs) print(forward(1, 2, host="db")) numbers = [1, 2, 3] print(total(*numbers)) # unpacking at the CALL site
def wrapper(*args, **kwargs) is the signature that forwards anything to anything, which is what makes generic decorators possible. It is also how type information gets lost, so annotate with ParamSpec when the wrapper is meant to be checked.
No overloading
Defining a function twice simply replaces the first definition — there is no overload resolution, because there are no static argument types to resolve on. Runtime dispatch on the first argument's type is available through functools.singledispatch.
trait Describe { fn describe(&self) -> String; } impl Describe for i32 { fn describe(&self) -> String { format!("the integer {self}") } } impl Describe for &str { fn describe(&self) -> String { format!("the text {self:?}") } } fn main() { println!("{}", 42.describe()); println!("{}", "ada".describe()); }
from functools import singledispatch @singledispatch def describe(value): return f"some object: {value!r}" @describe.register def _(value: int): return f"the integer {value}" @describe.register def _(value: str): return f"the text {value!r}" print(describe(42)) print(describe("ada")) print(describe([1, 2])) # falls back to the base implementation
In practice most code takes the duck-typing route instead: accept whatever has the right methods and let it work. typing.overload exists too, but it only describes signatures for the static checker — the runtime body is still a single function.
Generics
The syntax is nearly identical since 3.12, and the semantics could not be more different: generics are erased entirely at run time. There is no monomorphization, no bound to satisfy, and nothing stops largest from being called with a list of objects that cannot be compared.
fn first<T: Clone>(items: &[T]) -> Option<T> { items.first().cloned() } fn largest<T: PartialOrd + Copy>(items: &[T]) -> T { let mut winner = items[0]; for &item in items { if item > winner { winner = item; } } winner } fn main() { println!("{:?}", first(&[10, 20])); println!("{:?}", first(&["ada", "grace"])); println!("{}", largest(&[3.5, 9.1, 2.0])); }
def first[T](items: list[T]) -> T | None: # PEP 695 syntax, 3.12+ return items[0] if items else None print(first([10, 20])) print(first(["ada", "grace"])) def largest[T](items: list[T]) -> T: winner = items[0] for item in items: if item > winner: # no bound needed; duck typing decides winner = item return winner print(largest([3.5, 9.1, 2.0])) print(largest(["ada", "grace"])) # works on anything supporting >
Bounds do exist for the checker — def largest[T: (int, float)] constrains it — but they vanish at run time. A TypeError: '<' not supported between instances of ... is the runtime substitute for the trait-bound error rustc would have given you.
Closures & Decorators
Closures
There is no Fn/FnMut/FnOnce distinction and no move: a nested function captures its enclosing names by reference, resolved when it is called. Rebinding a captured name requires declaring it nonlocal (or global).
fn main() { let factor = 3; let scale = |value: i32| value * factor; // borrows `factor` println!("{}", scale(5)); let mut counter = 0; let mut bump = || { counter += 1; counter }; // FnMut println!("{} {}", bump(), bump()); let owned = String::from("moved"); let consume = move || println!("{owned}"); // FnOnce over moved data consume(); }
factor = 3 scale = lambda value: value * factor # one expression only print(scale(5)) def make_counter(): counter = 0 def bump(): nonlocal counter # without this, counter is local counter += 1 return counter return bump bump = make_counter() print(bump(), bump()) # Capture is BY REFERENCE and late-bound — the classic trap: late = [lambda: index for index in range(3)] print([function() for function in late]) # [2, 2, 2] early = [lambda index=index: index for index in range(3)] print([function() for function in early]) # [0, 1, 2]
Late binding is the trap worth memorizing: every closure in a loop sees the loop variable's final value, because they all share one binding. The default-argument trick shown above is the standard workaround, and it is what move would have done for you.
Functions as values
Passing and returning functions needs no type machinery at all: no generic parameter with an Fn bound, no impl Fn return, no Box<dyn Fn>. A function is an object, and so is a class, a method, and anything with __call__.
fn apply_twice<F: Fn(i32) -> i32>(function: F, value: i32) -> i32 { function(function(value)) } fn make_adder(amount: i32) -> impl Fn(i32) -> i32 { move |value| value + amount } fn main() { println!("{}", apply_twice(|value| value * 2, 5)); let add_ten = make_adder(10); println!("{}", add_ten(1)); let boxed: Box<dyn Fn(i32) -> i32> = Box::new(|value| value - 1); println!("{}", boxed(10)); }
def apply_twice(function, value): return function(function(value)) def make_adder(amount): return lambda value: value + amount print(apply_twice(lambda value: value * 2, 5)) print(make_adder(10)(1)) # Every callable is just an object — no Box, no dyn, no impl Trait: from functools import partial add_ten = partial(lambda left, right: left + right, 10) print(add_ten(1)) operations = {"double": lambda value: value * 2, "negate": lambda value: -value} print(operations["double"](21))
functools.partial is the currying tool and produces something inspectable and picklable, which a lambda is not. Storing callables in a dict is the everyday replacement for a dispatch table of trait objects.
Decorators
A decorator is a function that takes a function and returns a replacement; @name above a def is just work = instrument(work). This is the feature with no Rust counterpart short of a proc macro — and here it is ordinary code, written in the same file, with no build machinery.
// The nearest equivalent is an attribute proc-macro, which // lives in a separate proc-macro crate and rewrites the // function's token stream at compile time: // // #[instrument] // fn work(value: i32) -> i32 { value * 2 } // // Without one, wrapping is manual: fn instrument<F: Fn(i32) -> i32>(name: &str, function: F, value: i32) -> i32 { println!("calling {name}({value})"); let result = function(value); println!("{name} returned {result}"); result } fn main() { println!("{}", instrument("work", |value| value * 2, 21)); }
import functools def instrument(function): @functools.wraps(function) # keeps __name__ and the docstring def wrapper(*args, **kwargs): print(f"calling {function.__name__}{args}") result = function(*args, **kwargs) print(f"{function.__name__} returned {result}") return result return wrapper @instrument def work(value): return value * 2 print(work(21)) print(work.__name__) @functools.lru_cache(maxsize=None) # memoization, from the stdlib def slow_square(value): return value * value print(slow_square(9), slow_square.cache_info().misses)
Always apply functools.wraps in the wrapper, or the decorated function loses its name, docstring, and signature. Decorators are everywhere in real code: @property, @dataclass, @lru_cache, and every web framework's routing table.
Classes & Objects
Structs become dataclasses
@dataclass is #[derive(Debug, Clone, PartialEq)] — it generates __init__, __repr__, and __eq__ from the annotated fields. frozen=True makes instances immutable and hashable; slots=True drops the per-instance dict, which is a real memory win.
#[derive(Debug, Clone, PartialEq)] struct Measurement { label: String, value: f64, unit: String, } fn main() { let reading = Measurement { label: String::from("depth"), value: 12.5, unit: String::from("m"), }; println!("{reading:?}"); println!("{}", reading == reading.clone()); }
from dataclasses import dataclass, field, replace @dataclass(frozen=True, slots=True) class Measurement: label: str value: float unit: str = "m" # a default, right in the field list tags: list[str] = field(default_factory=list) reading = Measurement(label="depth", value=12.5) print(reading) # __repr__ for free print(reading == Measurement("depth", 12.5)) # __eq__ for free print(replace(reading, value=13.0)) # ..Default::default() equivalent
Note field(default_factory=list): the same mutable-default rule applies to fields, and the dataclass machinery raises at class-creation time if you write tags: list = []. That is one of the very few places Python refuses to let the bug through.
Methods and self
There is no separate impl block — methods live in the class body — and self is an explicit first parameter rather than a keyword. Mutability is not part of the signature, so nothing distinguishes a reader from a mutator except the name.
struct Counter { count: u32, } impl Counter { fn new() -> Self { // associated function Counter { count: 0 } } fn value(&self) -> u32 { // &self self.count } fn bump(&mut self) { // &mut self self.count += 1; } } fn main() { let mut counter = Counter::new(); counter.bump(); counter.bump(); println!("{}", counter.value()); }
class Counter: def __init__(self): # the constructor, not a factory self.count = 0 def value(self): # `self` is always explicit return self.count def bump(self): # no &self / &mut self distinction self.count += 1 @classmethod def starting_at(cls, start): # an associated function counter = cls() counter.count = start return counter @staticmethod def describe(): return "counts things" counter = Counter() counter.bump(); counter.bump() print(counter.value(), Counter.starting_at(10).value(), Counter.describe())
__init__ initializes an already-created object and returns None; it is not Counter::new. Alternative constructors become @classmethods taking cls, which is how you get the Self::-returning factory pattern.
Inheritance
Implementation inheritance is real here, and it is multiple. Attribute lookup walks the method resolution order — a linearization of the class graph — so super() means "the next class in the MRO", not "my parent".
// Rust has no inheritance. Shared behavior comes from a // trait with default methods, and reuse from composition: trait Shape { fn area(&self) -> f64; fn describe(&self) -> String { format!("a shape of area {:.2}", self.area()) } } struct Square { side: f64 } impl Shape for Square { fn area(&self) -> f64 { self.side * self.side } } fn main() { let square = Square { side: 3.0 }; println!("{}", square.describe()); }
class Shape: def area(self): raise NotImplementedError def describe(self): return f"a shape of area {self.area():.2f}" class Square(Shape): def __init__(self, side): self.side = side def area(self): return self.side * self.side class Labeled: def describe(self): return "labeled: " + super().describe() class LabeledSquare(Labeled, Square): # multiple inheritance pass print(Square(3).describe()) print(LabeledSquare(3).describe()) print([klass.__name__ for klass in LabeledSquare.__mro__])
That distinction matters: in LabeledSquare, Labeled.describe's super() resolves to Square, a class it knows nothing about. Composition is still the better default for the same reasons it is in Rust; deep hierarchies here fail the same way they do everywhere.
Operator overloading
Every operator maps to a dunder method, and the mapping is the same one Rust's operator traits use: __add__ is Add, __eq__ is PartialEq, __str__ is Display, __repr__ is Debug. The difference is that they are defined in the class, not in a separate impl.
use std::fmt; use std::ops::Add; #[derive(Debug, Clone, Copy, PartialEq)] struct Vector2 { x: f64, y: f64 } impl Add for Vector2 { type Output = Vector2; fn add(self, other: Vector2) -> Vector2 { Vector2 { x: self.x + other.x, y: self.y + other.y } } } impl fmt::Display for Vector2 { fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result { write!(formatter, "({}, {})", self.x, self.y) } } fn main() { let sum = Vector2 { x: 1.0, y: 2.0 } + Vector2 { x: 3.0, y: 4.0 }; println!("{sum} {sum:?}"); }
class Vector2: def __init__(self, x, y): self.x, self.y = x, y def __add__(self, other): # the Add trait return Vector2(self.x + other.x, self.y + other.y) def __eq__(self, other): # PartialEq return (self.x, self.y) == (other.x, other.y) def __str__(self): # Display return f"({self.x}, {self.y})" def __repr__(self): # Debug return f"Vector2(x={self.x}, y={self.y})" def __len__(self): return 2 total = Vector2(1, 2) + Vector2(3, 4) print(total, repr(total), len(total)) print(total == Vector2(4, 6))
There is no coherence rule, so nothing prevents two libraries from disagreeing about a type. If __add__ cannot handle the other operand it should return NotImplemented, which makes Python try the right operand's __radd__ before raising TypeError. Defining __eq__ without __hash__ makes instances unhashable.
Properties
A @property turns a method into an attribute read, and its .setter turns assignment into a method call. Rust has no such thing — accessors are always explicit calls — so reading.fahrenheit = 32 here runs arbitrary code.
struct Temperature { celsius: f64, } impl Temperature { fn fahrenheit(&self) -> f64 { self.celsius * 9.0 / 5.0 + 32.0 } fn set_fahrenheit(&mut self, value: f64) { self.celsius = (value - 32.0) * 5.0 / 9.0; } } fn main() { let mut reading = Temperature { celsius: 100.0 }; println!("{}", reading.fahrenheit()); reading.set_fahrenheit(32.0); println!("{}", reading.celsius); }
class Temperature: def __init__(self, celsius): self.celsius = celsius @property def fahrenheit(self): # read like a field return self.celsius * 9 / 5 + 32 @fahrenheit.setter def fahrenheit(self, value): # assigned like a field self.celsius = (value - 32) * 5 / 9 reading = Temperature(100) print(reading.fahrenheit) # no parentheses reading.fahrenheit = 32 # runs the setter print(reading.celsius)
The practical consequence is that public attributes are safe to expose: if a field later needs validation or computation, it becomes a property and every existing caller keeps working. That is why Python code rarely has get_/set_ pairs.
Objects are open
Instances carry a __dict__, so assigning to any name creates an attribute — including a misspelled one. This is the failure mode that costs the most in ported Rust code, because it is silent and the wrong value shows up somewhere else entirely.
struct Account { balance: f64, } fn main() { let mut account = Account { balance: 10.0 }; account.balance += 5.0; // account.blance = 0.0; // error: no field `blance` on type `Account` // // caught at compile time, always println!("{}", account.balance); }
class Account: def __init__(self, balance): self.balance = balance account = Account(10) account.balance += 5 account.blance = 0 # a TYPO — silently creates a new attribute print(account.balance, account.__dict__) class StrictAccount: __slots__ = ("balance",) # fixes the attribute set def __init__(self, balance): self.balance = balance strict = StrictAccount(10) try: strict.blance = 0 except AttributeError as error: print("AttributeError:", error)
__slots__ (or @dataclass(slots=True)) closes the set of attributes and turns the typo into an AttributeError, while also shrinking each instance. A type checker catches it too. Use one of the two on any class that models data.
Duck Typing & Protocols
Duck typing
Nothing declares conformance and nothing needs to: a function that calls .speak() accepts any object that has one. There is no trait, no impl, no dyn, and no vtable — attribute lookup happens per call.
trait Speak { fn speak(&self) -> String; } struct Dog; struct Robot; impl Speak for Dog { fn speak(&self) -> String { String::from("woof") } } impl Speak for Robot { fn speak(&self) -> String { String::from("beep") } } fn announce(speaker: &dyn Speak) { println!("{}", speaker.speak()); } fn main() { announce(&Dog); announce(&Robot); }
class Dog: def speak(self): return "woof" class Robot: def speak(self): # declares no relationship to Dog return "beep" def announce(speaker): print(speaker.speak()) announce(Dog()) announce(Robot()) class Silent: pass try: announce(Silent()) except AttributeError as error: print("AttributeError:", error)
The failure mode moves from build time to the moment the method is reached, and it arrives as AttributeError rather than a type error naming the missing trait. That is the entire trade: no boilerplate to connect a type to an interface, no guarantee that the connection exists.
Protocols are structural traits
typing.Protocol is a trait checked structurally and statically: a class satisfies it by having the right methods, with no impl and no import of the protocol. It is the annotation that lets a checker verify duck typing.
trait Drawable { fn draw(&self) -> String; } struct Circle { radius: f64 } impl Drawable for Circle { fn draw(&self) -> String { format!("circle r={}", self.radius) } } fn render(shape: &impl Drawable) -> String { shape.draw() } fn main() { println!("{}", render(&Circle { radius: 2.0 })); }
from typing import Protocol, runtime_checkable @runtime_checkable class Drawable(Protocol): def draw(self) -> str: ... class Circle: # no base class, no registration def __init__(self, radius): self.radius = radius def draw(self) -> str: return f"circle r={self.radius}" def render(shape: Drawable) -> str: return shape.draw() print(render(Circle(2))) print(isinstance(Circle(2), Drawable)) # structural check, opt-in
Plain isinstance against a Protocol raises unless you add @runtime_checkable, and even then it only checks that the method names exist — never the signatures. The static checker is where the real guarantee lives.
Abstract base classes
An ABC is the nominal alternative to a Protocol: implementations inherit from it explicitly, and @abstractmethod members must be provided or instantiation raises. Default methods on the base fill the same role as trait default methods.
trait Repository { fn get(&self, id: u32) -> Option<String>; fn describe(&self) -> String { String::from("a repository") // a default method } } struct InMemory { rows: Vec<String> } impl Repository for InMemory { fn get(&self, id: u32) -> Option<String> { self.rows.get(id as usize).cloned() } } fn main() { let repository = InMemory { rows: vec![String::from("ada")] }; println!("{:?} {}", repository.get(0), repository.describe()); }
from abc import ABC, abstractmethod class Repository(ABC): @abstractmethod def get(self, identifier): ... def describe(self): # a default method return "a repository" class InMemory(Repository): def __init__(self, rows): self.rows = rows def get(self, identifier): return self.rows[identifier] if identifier < len(self.rows) else None repository = InMemory(["ada"]) print(repository.get(0), repository.describe()) class Incomplete(Repository): pass try: Incomplete() except TypeError as error: print("TypeError:", error)
Prefer Protocol when you are describing what a function needs, and ABC when you are building a class hierarchy you control and want the runtime error. Unlike a trait, an ABC cannot be implemented for a type you do not own — except via register, which skips the checks entirely.
No orphan rule
There is no orphan rule and no coherence checking, because there is no compile-time resolution to keep consistent. Methods can be added to any class — yours or a library's — at any point during execution, and to individual instances as well.
// The orphan rule forbids implementing a foreign trait for // a foreign type. The newtype pattern is the way around it: use std::fmt; struct Shouted(String); impl fmt::Display for Shouted { fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result { write!(formatter, "{}!", self.0.to_uppercase()) } } fn main() { println!("{}", Shouted(String::from("ada"))); }
class Greeter: def hello(self): return "hello" def shout(self): return self.hello().upper() + "!" Greeter.shout = shout # added to a class that never declared it print(Greeter().shout()) instance = Greeter() instance.hello = lambda: "patched on ONE instance" print(instance.hello(), Greeter().hello()) # Built-in types are the one place this is refused: try: str.shout = shout except TypeError as error: print("TypeError:", error)
That power is why the ecosystem has test fixtures like unittest.mock.patch and why a library can extend another. It is also why "who defined this method?" can be genuinely unanswerable, so treat monkey-patching as a tool for tests and last-resort fixes, not architecture. C-level built-ins are immune.
Error Handling
Exceptions, not Result
Failure is out-of-band. A function that can fail returns the value on success and raises otherwise, so its type says nothing about what can go wrong and no compiler makes the caller consider it — #[must_use] has no counterpart.
fn parse_age(text: &str) -> Result<u32, String> { text.parse::<u32>().map_err(|error| format!("bad age: {error}")) } fn main() { match parse_age("42") { Ok(age) => println!("age {age}"), Err(message) => println!("{message}"), } match parse_age("old") { Ok(age) => println!("age {age}"), Err(message) => println!("{message}"), } }
def parse_age(text): return int(text) # raises ValueError; the signature says nothing try: print("age", parse_age("42")) except ValueError as error: print("bad age:", error) try: print("age", parse_age("old")) except ValueError as error: print("bad age:", error) # Nothing forces the try. This line is a latent crash: def total_age(entries): return sum(int(entry) for entry in entries) print(total_age(["1", "2"]))
The upside is that error propagation is free and the happy path stays uncluttered; the downside is that the set of possible exceptions is documentation, not signature. Catch the specific exception type, never a bare except:, and let anything you cannot handle propagate.
Propagation is automatic
There is no ? because every function already behaves as if every call had one — an exception unwinds until something catches it. The interesting operation is therefore the opposite of propagation: stopping it, or adding context on the way past.
use std::num::ParseIntError; fn parse_pair(text: &str) -> Result<(i32, i32), ParseIntError> { let mut parts = text.split(','); let left: i32 = parts.next().unwrap_or("").trim().parse()?; // ? propagates let right: i32 = parts.next().unwrap_or("").trim().parse()?; Ok((left, right)) } fn main() { println!("{:?}", parse_pair("3, 4")); println!("{:?}", parse_pair("3, x").is_err()); }
def parse_pair(text): left, right = text.split(",") return int(left), int(right) # no `?` — failure propagates by itself print(parse_pair("3, 4")) try: parse_pair("3, x") except ValueError as error: print("propagated up:", error) # Adding context is a re-raise with `from`, which keeps the cause: def load_config(text): try: return parse_pair(text) except ValueError as error: raise ValueError(f"invalid config {text!r}") from error try: load_config("3, x") except ValueError as error: print(error, "| caused by:", type(error.__cause__).__name__)
raise NewError(...) from original is the anyhow::Context equivalent and preserves the original in __cause__, so the traceback shows both. Re-raising without from still chains implicitly, but marks the relationship as accidental rather than intended.
try / except / else / finally
Four clauses, and the middle two are easy to miss. else runs only when the try block raised nothing, which keeps the "this line is the risky one" scope minimal; finally runs on every path, including through a return.
fn read_value(source: &str) -> Result<i32, String> { let parsed = source.parse::<i32>().map_err(|_| String::from("not a number"))?; if parsed < 0 { return Err(String::from("negative")); } Ok(parsed * 2) } fn main() { for source in ["21", "-1", "abc"] { match read_value(source) { Ok(value) => println!("{source} -> {value}"), Err(message) => println!("{source} -> error: {message}"), } println!(" (cleanup for {source})"); } }
def read_value(source): try: parsed = int(source) except ValueError: return "error: not a number" except (TypeError, OverflowError) as error: # several at once return f"error: {error}" else: # runs only when NOTHING was raised if parsed < 0: return "error: negative" return parsed * 2 finally: # runs on every path, including the returns above print(f" (cleanup for {source})") for source in ["21", "-1", "abc"]: print(source, "->", read_value(source))
finally is the last-resort cleanup mechanism, but a context manager (with) is the idiomatic one — it puts the cleanup next to the resource instead of next to every use. Since 3.11 there are also exception groups and except*, which is how asyncio.TaskGroup reports several concurrent failures at once.
Custom error types
The enum-of-variants shape becomes a class hierarchy: a base exception for the family, one subclass per case, and any extra data as attributes. Catching the base catches every variant, which is how except substitutes for matching on an enum.
use std::fmt; #[derive(Debug)] enum ConfigError { Missing(String), Invalid { key: String, reason: String }, } impl fmt::Display for ConfigError { fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result { match self { ConfigError::Missing(key) => write!(formatter, "missing key {key}"), ConfigError::Invalid { key, reason } => write!(formatter, "invalid {key}: {reason}"), } } } impl std::error::Error for ConfigError {} fn main() { let error = ConfigError::Invalid { key: String::from("port"), reason: String::from("not a number"), }; println!("{error} / {error:?}"); }
class ConfigError(Exception): """Base class for every configuration problem.""" class MissingKey(ConfigError): def __init__(self, key): super().__init__(f"missing key {key}") self.key = key class InvalidValue(ConfigError): def __init__(self, key, reason): super().__init__(f"invalid {key}: {reason}") self.key, self.reason = key, reason for error in (MissingKey("host"), InvalidValue("port", "not a number")): try: raise error except ConfigError as caught: # one except catches the family print(type(caught).__name__, "-", caught)
Always inherit from Exception, never from BaseException — the latter is reserved for KeyboardInterrupt and SystemExit, which almost nothing should catch. Since the hierarchy is open, callers can catch at whatever granularity they need without you enumerating it.
EAFP over LBYL
The house style is to attempt the operation and catch the failure, rather than test first. Exceptions are cheap, and the check-then-act form has a race in it whenever the thing being checked is a file, a socket, or shared state.
use std::collections::HashMap; fn main() { let settings: HashMap<&str, &str> = [("host", "localhost")].into_iter().collect(); // Checking first is the natural Rust shape, because the // check RETURNS the value: if let Some(host) = settings.get("host") { println!("host is {host}"); } else { println!("no host configured"); } let port = settings.get("port").copied().unwrap_or("5432"); println!("port is {port}"); }
settings = {"host": "localhost"} # EAFP — "easier to ask forgiveness than permission": try: print("host is", settings["host"]) except KeyError: print("no host configured") # LBYL — correct here, but racy for files and sockets: if "host" in settings: print("host is", settings["host"]) print("port is", settings.get("port", "5432")) # The EAFP payoff: this works for ANY object with the method, # without asking what type it is first. def length_of(value): try: return len(value) except TypeError: return None print(length_of([1, 2]), length_of(42))
This is culturally the biggest adjustment for a Rust programmer: try/except is ordinary control flow here, not an escape hatch. dict.get(key, default) is still the terse form when the fallback is a value, and it is the closest thing to unwrap_or.
Context managers
with is the scope-bound cleanup construct — __enter__ acquires, __exit__ releases, and the release runs even when the body raises. It is Drop made explicit at the use site rather than attached to the type.
use std::sync::Mutex; fn main() { let counter = Mutex::new(0); { let mut guard = counter.lock().unwrap(); *guard += 1; } // the guard's Drop releases the lock, exception or not println!("{}", counter.lock().unwrap()); // File handles work the same way — Drop closes them. let text = String::from("no file I/O in this sandbox"); println!("{text}"); }
import contextlib, tempfile, os, threading lock = threading.Lock() with lock: # acquire / release, exception-safe print("inside the critical section") with tempfile.NamedTemporaryFile("w+", delete=False) as handle: handle.write("written and flushed on exit") path = handle.name print(open(path).read()) os.remove(path) @contextlib.contextmanager # the easy way to write one def timed(label): print("start", label) try: yield label # everything before = __enter__ finally: print("end", label) # everything after = __exit__ with timed("work") as label: print("doing", label)
@contextlib.contextmanager turns a generator into one: the code before yield is the acquire and the code after is the release, which is why the try/finally around the yield is not optional. contextlib.ExitStack handles a dynamic number of resources.
Pattern Matching
match without exhaustiveness
match/case arrived in 3.10 and is a genuine structural match, not a switch. What it is not is exhaustive: a missing case simply falls through to case _, or to nothing at all, and the statement quietly evaluates to no action.
enum Status { Active, Suspended { reason: String }, Closed, } fn describe(status: &Status) -> String { match status { Status::Active => String::from("active"), Status::Suspended { reason } => format!("suspended: {reason}"), Status::Closed => String::from("closed"), // omit an arm and the compiler refuses to build } } fn main() { println!("{}", describe(&Status::Suspended { reason: String::from("fraud") })); }
from enum import Enum, auto class Status(Enum): ACTIVE = auto() SUSPENDED = auto() CLOSED = auto() def describe(status, reason=None): match status: case Status.ACTIVE: return "active" case Status.SUSPENDED: return f"suspended: {reason}" # CLOSED is missing — nothing complains case _: return "unhandled" print(describe(Status.SUSPENDED, "fraud")) print(describe(Status.CLOSED)) # falls into the wildcard
Losing exhaustiveness is the single biggest safety regression on this page — adding an enum variant no longer breaks the build, it changes behavior. A type checker can restore it if every case is annotated and there is no catch-all; alternatively, case _: raise AssertionError(...) makes the gap loud at run time.
Structural patterns
Patterns go further than Rust's in one direction: alongside class patterns and guards, match destructures dicts and lists natively, so untyped JSON-shaped data can be matched without building types for it first.
#[derive(Debug)] enum Command { Move { x: i32, y: i32 }, Write(String), Quit, } fn run(command: Command) -> String { match command { Command::Move { x, y } if x == y => format!("diagonal to {x}"), Command::Move { x, y } => format!("move to {x},{y}"), Command::Write(text) if text.is_empty() => String::from("nothing to write"), Command::Write(text) => format!("write {text}"), Command::Quit => String::from("quit"), } } fn main() { println!("{}", run(Command::Move { x: 2, y: 2 })); println!("{}", run(Command::Write(String::from("ada")))); }
from dataclasses import dataclass @dataclass class Move: x: int y: int @dataclass class Write: text: str def run(command): match command: case Move(x=x, y=y) if x == y: # class pattern with a guard return f"diagonal to {x}" case Move(x=x, y=y): return f"move to {x},{y}" case Write(text=""): # matches a literal field value return "nothing to write" case Write(text=text): return f"write {text}" case {"action": action, **rest}: # mapping patterns, too return f"dict action {action} with {rest}" case [first, *others]: # and sequence patterns return f"list starting {first}, {len(others)} more" case _: return "unknown" print(run(Move(2, 2)), "|", run(Write("ada"))) print(run({"action": "sync", "force": True}), "|", run([1, 2, 3]))
The one syntax trap: a bare name in a pattern is a capture, not a comparison, so case Status.ACTIVE works (dotted) while case ACTIVE would bind every value to a new variable named ACTIVE. Constants in patterns must always be dotted.
None is not Option
None is a plain singleton, not a wrapper — there is no Some, no unwrap, and nothing that makes a caller acknowledge it. The annotation str | None tells the checker; the interpreter is unaffected.
fn find_user(id: u32) -> Option<String> { if id == 1 { Some(String::from("ada")) } else { None } } fn main() { // The compiler will not let you use it without unwrapping: match find_user(1) { Some(name) => println!("{}", name.to_uppercase()), None => println!("not found"), } println!("{}", find_user(2).unwrap_or_else(|| String::from("anonymous"))); println!("{:?}", find_user(1).map(|name| name.len())); }
def find_user(identifier): return "ada" if identifier == 1 else None # the return type is a lie name = find_user(1) if name is not None: print(name.upper()) missing = find_user(2) print(missing or "anonymous") # careful: "" would also fall through print(missing if missing is not None else "anonymous") # the correct form # Nothing forces the check. This is the Python NullPointerException: try: print(find_user(2).upper()) except AttributeError as error: print("AttributeError:", error)
value or default is the tempting unwrap_or and it is subtly wrong, because it also replaces 0, "", and empty containers. Write the explicit is not None test unless you have decided that all falsy values should take the fallback.
Concurrency & the GIL
The GIL
The Global Interpreter Lock lets exactly one thread execute Python bytecode at a time, so four CPU-bound threads take as long as one — and slightly longer, from the switching. The four threads above are correct and produce the right answer; what they do not produce is speed.
use std::thread; fn main() { // Real parallelism: N threads, N cores, no interpreter lock. let handles: Vec<_> = (0..4) .map(|worker| { thread::spawn(move || { let total: u64 = (0..200_000u64).map(|value| value % 7).sum(); (worker, total) }) }) .collect(); for handle in handles { let (worker, total) = handle.join().unwrap(); println!("worker {worker} -> {total}"); } }
import sys, threading def work(worker, results): results[worker] = sum(value % 7 for value in range(200_000)) results = {} threads = [threading.Thread(target=work, args=(worker, results)) for worker in range(4)] for thread in threads: thread.start() for thread in threads: thread.join() for worker in sorted(results): print(f"worker {worker} -> {results[worker]}") # On a standard build, only one of those threads ran Python # bytecode at a time: print("GIL enabled:", getattr(sys, "_is_gil_enabled", lambda: True)())
Threads are still the right tool for I/O, because the GIL is released around blocking calls and inside C extensions like numpy. For CPU work the answers are multiprocessing, a native extension (this is what PyO3 is for), or a free-threaded build — the next rows.
No Send, no Sync
Any object can be touched by any thread. There is no Send, no Sync, no Arc, and no compiler objection to sharing a mutable structure — global counter plus four threads compiles, runs, and is wrong unless you remember the lock yourself.
use std::sync::{Arc, Mutex}; use std::thread; fn main() { let counter = Arc::new(Mutex::new(0)); let mut handles = Vec::new(); for _ in 0..4 { let counter = Arc::clone(&counter); handles.push(thread::spawn(move || { for _ in 0..1000 { *counter.lock().unwrap() += 1; // the lock is UNAVOIDABLE } })); } for handle in handles { handle.join().unwrap(); } println!("{}", *counter.lock().unwrap()); }
import threading counter = 0 # shared, unguarded, and perfectly legal lock = threading.Lock() def bump_unsafe(): global counter for _ in range(1000): counter += 1 # read-modify-write: NOT atomic def bump_safe(): global counter for _ in range(1000): with lock: counter += 1 threads = [threading.Thread(target=bump_safe) for _ in range(4)] for thread in threads: thread.start() for thread in threads: thread.join() print(counter) # 4000, because the lock was used
The GIL makes many individual bytecode operations look atomic, which hides the bug rather than removing it: counter += 1 is three operations and can interleave. Never rely on the GIL for correctness — use Lock, or better, hand work to a queue.Queue and keep the state in one thread.
async/await
asyncio is in the standard library, so there is no runtime to choose and no #[tokio::main]. Coroutines are lazy like futures — calling fetch(1) runs nothing until it is awaited or scheduled — but the event loop is single-threaded, so awaiting is concurrency without parallelism.
// Rust's async needs an executor from the ecosystem // (tokio, async-std, smol) — the language ships the syntax // and the Future trait, nothing that runs them: // // #[tokio::main] // async fn main() { // let (first, second) = tokio::join!(fetch(1), fetch(2)); // } // // Futures are lazy: nothing happens until they are polled. async fn double(value: i32) -> i32 { value * 2 } fn main() { // Without a runtime, the future is only a value: let pending = double(21); println!("a Future that has not run: {}", std::mem::size_of_val(&pending)); }
import asyncio async def fetch(identifier): await asyncio.sleep(0.01) # yields to the event loop return f"result {identifier}" async def main(): # The runtime ships WITH the language — no tokio to choose. first, second = await asyncio.gather(fetch(1), fetch(2)) print(first, second) async with asyncio.TaskGroup() as group: # 3.11+, structured concurrency tasks = [group.create_task(fetch(index)) for index in range(3)] print([task.result() for task in tasks]) asyncio.run(main())
There is no Send bound on a task and no borrow checker, so the data-sharing rules that shape Rust async simply are not there. TaskGroup is the structured-concurrency primitive: it awaits every child and collects failures into an ExceptionGroup, which is what except* exists to unpack.
Processes for parallelism
Because threads cannot use more than one core, CPU parallelism means processes. ProcessPoolExecutor has the same interface as the thread pool above, so switching is one identifier — but arguments and results must be picklable, since they cross a process boundary.
use std::thread; fn main() { // Threads already give real parallelism, so there is no // reason to reach for processes. rayon shortens it further: // values.par_iter().map(expensive).sum() let chunks = vec![0..250_000u64, 250_000..500_000u64]; let handles: Vec<_> = chunks .into_iter() .map(|range| thread::spawn(move || range.map(|value| value % 7).sum::<u64>())) .collect(); let total: u64 = handles.into_iter().map(|handle| handle.join().unwrap()).sum(); println!("{total}"); }
from concurrent.futures import ThreadPoolExecutor def partial_sum(bounds): start, stop = bounds return sum(value % 7 for value in range(start, stop)) # ProcessPoolExecutor is the real-parallelism version — same API, # but each worker is a separate interpreter with its own GIL, and # every argument and result is PICKLED across the boundary: # # with ProcessPoolExecutor() as pool: # total = sum(pool.map(partial_sum, chunks)) chunks = [(0, 250_000), (250_000, 500_000)] with ThreadPoolExecutor() as pool: print(sum(pool.map(partial_sum, chunks)))
That serialization cost is the reason "just use multiprocessing" is not the universal answer: passing a large array to a worker copies it. It is also why the tightest loops get rewritten in Rust with PyO3 instead — one process, no pickling, and the extension releases the GIL while it runs.
Free-threaded and subinterpreters
Python 3.14 is the release where the GIL stops being permanent. The free-threaded build (python3.14t) removes it outright and is now officially supported rather than experimental, and concurrent.interpreters puts several isolated interpreters — each with its own GIL — inside one process.
// Rust has never had this problem — threads are threads, // and the type system decides what may cross between them: use std::sync::mpsc; use std::thread; fn main() { let (sender, receiver) = mpsc::channel(); for worker in 0..3 { let sender = sender.clone(); thread::spawn(move || sender.send(worker * 10).unwrap()); } drop(sender); let mut received: Vec<i32> = receiver.iter().collect(); received.sort(); println!("{received:?}"); }
import queue, threading # Channels: queue.Queue is mpsc, and it is thread-safe by design. channel = queue.Queue() for worker in range(3): threading.Thread(target=channel.put, args=(worker * 10,)).start() received = sorted(channel.get() for _ in range(3)) print(received) # 3.14 ships two escapes from the GIL: # 1. Free-threaded builds (PEP 779) — officially supported, # installed as python3.14t, no GIL at all. # 2. concurrent.interpreters (PEP 734) — isolated interpreters # in ONE process, each with its own GIL: try: from concurrent import interpreters worker = interpreters.create() worker.exec("print('hello from a second interpreter')") worker.close() except ImportError: print("concurrent.interpreters is not available in this build")
Neither gives you Rust's guarantees: removing the GIL removes the accidental atomicity that hid races, so free-threaded code needs its locks to be right, not merely present. Subinterpreters sidestep that by sharing nothing, which makes them closer to processes without the fork cost.
Packaging & Rust Interop
Modules and imports
The module tree is the directory tree — no mod statements, no lib.rs. import x binds the module object; from x import y binds the member; as renames. Every name in a module is importable, since there is no pub.
// A module is declared in code and rooted in the crate: // // src/main.rs mod geometry; use geometry::area; // src/geometry.rs pub fn area(...) -> f64 { ... } // // Visibility is explicit: private by default, pub to export. mod geometry { pub fn area(width: f64, height: f64) -> f64 { width * height } #[allow(dead_code)] fn internal_helper() -> f64 { 0.0 } // not visible outside } use geometry::area; fn main() { println!("{}", area(3.0, 4.0)); }
# A module is a FILE, and a package is a directory. There is # no `mod` declaration — the filesystem is the module tree: # # geometry.py -> import geometry # shapes/__init__.py -> from shapes import circle # # Everything is public; a leading underscore is a convention. import math from math import sqrt, pi as circle_ratio from collections import defaultdict as tally print(math.floor(3.7), sqrt(16), round(circle_ratio, 3)) print(tally(int)["missing"]) # Imports are executed at run time, so they can be conditional: try: import tomllib print("tomllib available:", tomllib is not None) except ImportError: print("no tomllib")
Two consequences worth internalizing: an import executes the module the first time (side effects included, cached afterward), and circular imports are a runtime failure rather than a build error. A leading underscore means "internal" by convention only.
pyproject.toml vs Cargo.toml
pyproject.toml is the standardized manifest and reads much like Cargo.toml, including the build backend. What has no counterpart is Cargo itself: there is no single tool that resolves, locks, builds, tests, and publishes, so a project also picks its front end.
// Cargo.toml — one tool, one lockfile, one way: // // [package] // name = "analyzer" // version = "0.1.0" // edition = "2024" // // [dependencies] // serde = { version = "1", features = ["derive"] } // // cargo add / build / test / run / publish // Cargo.lock is committed; the target directory is per-project. fn main() { println!("one build tool, and it came with the compiler"); }
# pyproject.toml — the same shape, standardized much later: # # [project] # name = "analyzer" # version = "0.1.0" # requires-python = ">=3.14" # dependencies = ["httpx>=0.27", "pydantic>=2"] # # [build-system] # requires = ["hatchling"] # build-backend = "hatchling.build" # # The tooling is NOT unified — pip, uv, poetry, pdm, hatch all # read this file and do different subsets of the job: # # uv add httpx uv sync uv run pytest # pip install -e . python -m build import sys print("interpreter:", sys.version.split()[0]) print("import path entries:", len(sys.path))
uv — written in Rust — is the closest thing to Cargo that Python has, and it is winning for exactly that reason: one binary, a real lockfile, and resolution in milliseconds. If you are starting a project today, start with uv.
Virtual environments
This is the piece with no Rust analog at all. Packages install into an interpreter, globally, so projects need a private interpreter each — that is what a virtual environment is. Forgetting to activate one is the most common way a Python setup breaks.
// Dependencies are per-project by construction. Two crates // may even depend on different major versions of the same // library and both are compiled in — there is nothing global // to conflict over, and no environment to activate: // // cargo add serde@1 // cargo build # resolves into ./target and Cargo.lock fn main() { println!("no environment to activate; the crate IS the environment"); }
# Installation is per-INTERPRETER, so isolation is a directory # holding its own interpreter and site-packages: # # python3 -m venv .venv # the stdlib way # source .venv/bin/activate # pip install httpx # # uv venv && uv sync # the fast way # # Exactly ONE version of a package can be installed per # environment — the diamond that Cargo resolves by compiling # both is a conflict here that someone must resolve by hand. import sys, sysconfig print("running from:", sys.prefix != sys.base_prefix and "a venv" or "the base interpreter") print("packages land in:", sysconfig.get_path("purelib").split("/")[-3:])
The deeper difference is that only one version of a dependency can exist per environment, so two libraries needing incompatible versions of a third is a genuine dead end, where Cargo would simply compile both. It is the main reason dependency resolution here is a negotiation rather than a computation.
Testing
Tests are not part of the language: there is no #[test] attribute and no built-in runner worth using. unittest ships in the standard library, but essentially every real project uses pytest, which finds functions named test_* and reads plain assert statements.
fn normalize(text: &str) -> String { text.trim().to_lowercase() } #[cfg(test)] mod tests { use super::*; #[test] fn trims_and_lowercases() { assert_eq!(normalize(" ADA "), "ada"); } #[test] #[should_panic] fn demonstrates_should_panic() { panic!("expected"); } } fn main() { println!("{}", normalize(" ADA ")); // cargo test runs the module above }
# Tests live in test_*.py files, NOT beside the code, and pytest # discovers them. There is no #[cfg(test)] and no test harness # in the standard toolchain — pytest is a dependency you add. # # def test_trims_and_lowercases(): # assert normalize(" ADA ") == "ada" # plain assert # # def test_raises(): # with pytest.raises(ValueError): # int("nope") # # pytest -q # discovery by naming convention def normalize(text): return text.strip().lower() # unittest ships with Python, so the stdlib-only version runs here: import unittest class NormalizeTests(unittest.TestCase): def test_trims_and_lowercases(self): self.assertEqual(normalize(" ADA "), "ada") def test_raises_on_bad_input(self): with self.assertRaises(AttributeError): normalize(None) suite = unittest.TestLoader().loadTestsFromTestCase(NormalizeTests) result = unittest.TextTestRunner(verbosity=0).run(suite) print("ran", result.testsRun, "tests, failures:", len(result.failures))
Because there is no compiler, the coverage bar is higher than in Rust — tests are carrying load that rustc carries for you, including "does this function even exist". Doctests exist too (python -m doctest), and are closer to Rust's than anything else here.
PyO3 and maturin
PyO3 exposes Rust functions as a native Python module and maturin builds and installs it into the active virtual environment. #[pyfunction] handles the argument conversion, so the Python caller sees an ordinary function.
// The Rust side of a native extension. Cargo.toml: // // [lib] // crate-type = ["cdylib"] // // [dependencies] // pyo3 = { version = "0.27", features = ["extension-module"] } // // use pyo3::prelude::*; // // #[pyfunction] // fn sum_of_squares(values: Vec<u64>) -> u64 { // values.iter().map(|value| value * value).sum() // } // // #[pymodule] // fn fastmath(module: &Bound<'_, PyModule>) -> PyResult<()> { // module.add_function(wrap_pyfunction!(sum_of_squares, module)?) // } // // maturin develop build and install into the active venv // maturin build --release fn main() { println!("compiled to a .so that Python imports like any module"); }
# The Python side is just an import — the extension is # indistinguishable from a pure-Python module: # # import fastmath # fastmath.sum_of_squares([1, 2, 3]) # runs Rust, releases the GIL # # This is the single most common reason a Rust programmer is # reading this page: the hot 5% moves to Rust, the other 95% # stays in Python, and PyO3 is the boundary. def sum_of_squares(values): return sum(value * value for value in values) print(sum_of_squares([1, 2, 3])) import sysconfig print("extensions are built as:", sysconfig.get_config_var("EXT_SUFFIX"))
A native extension releases the GIL while it runs, which is why this is the standard answer to "Python is too slow here" — real parallelism inside the extension, no pickling, no separate process. Both sides of this row are the same algorithm; only the placement of the boundary is the design decision.
Rust under the ecosystem
This is the commercial reality behind the pairing. Python's modern tooling layer is being rewritten in Rust — ruff, uv, polars, pydantic-core, cryptography, tokenizers — and the Python user sees only a fast import.
// The tools below are Rust programs that most Python // developers use daily without knowing it: // // ruff linter + formatter (replaces flake8, isort, black) // uv package manager (replaces pip, virtualenv, pip-tools) // polars dataframes (a pandas alternative) // pydantic-core validation engine behind pydantic v2 // cryptography the hazmat layer // tokenizers Hugging Face's tokenizer library // // Every one of them is a PyO3 extension or a standalone binary. fn main() { println!("Rust is the fastest-growing implementation language for Python tooling"); }
# From the Python side these are ordinary installs: # # uv tool install ruff # ruff check . && ruff format . # # uv add polars # import polars as pl # pl.read_csv("data.csv").group_by("city").agg(pl.col("temp").mean()) # # The pattern is always the same: a Python API you would have # written anyway, with the work done in Rust underneath. import platform, sys print("implementation:", platform.python_implementation()) print("version:", sys.version.split()[0]) print("the Rust you write here arrives as an ordinary import")
So the useful way to read this page is not "Rust versus Python" but "which side of the boundary does this belong on". Python owns the API, the glue, and the iteration speed; Rust owns the inner loop, the parser, and the resolver. Knowing both is the point.