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 manager — with 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 float — 7 / 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.