Rust for Python Developers: A Practical Guide
If you know Python, you already know a lot of Rust’s ideas. Rust has for loops over iterators, tuples, closures (Python’s lambdas, but more useful), a match statement that works like Python’s match, and iterator chains that do what list comprehensions do. Rust even borrows some of Python’s taste: one obvious way to format code, a friendly package tool, and error messages that try hard to help you.
What’s new is mostly about the compiler. Python checks types while your program runs; Rust checks them all before it runs. Python cleans up memory with a garbage collector; Rust uses ownership and borrowing, checked at compile time. Python has None and exceptions; Rust has Option and Result. Those three things are where you’ll spend most of your learning time.
So why do Python developers learn Rust? The honest reasons are these:
- Speed. Rust compiles to native machine code. CPU-heavy work that is slow in pure Python (parsing, number crunching, big loops) is usually much faster in Rust, with no tuning tricks.
- Real threads. In standard CPython, the GIL lets only one thread run Python code at a time. Rust threads run on all your CPU cores at once, and the compiler stops data races before your program runs.
- Bugs found earlier. A typo in an attribute name, a
Noneyou forgot about, or a function called with the wrong type: in Python you find these when that line runs. In Rust, the program doesn’t compile until they’re fixed. - You don’t have to choose. With PyO3 and maturin, you can write the slow part of a Python project in Rust and
importit from Python like any other module. Popular Python tools such as Polars, Ruff, and uv are written in Rust.
If you want a broader comparison of Rust with Python, Go, and C++, see Rust vs Other Languages.
Language concepts: Python to Rust
Section titled “Language concepts: Python to Rust”Use this table as a phrase book. The last column links to the lesson on this site that explains the Rust side.
| Python | Rust | Learn more |
|---|---|---|
| Dynamic types, checked at runtime | Static types, checked at compile time. Rust infers most of them, so you write fewer types than you’d think | Data Types |
Type hints (def f(x: int) -> str) |
Types in function signatures are required, and the compiler enforces them | Functions |
x = 5 |
let x = 5; (can’t change). let mut x = 5; if you want to change it |
Variables |
int (grows as big as needed) |
Fixed-size integers: i32, i64, u64, usize, i128… They can overflow |
Data Types, Integer Overflow |
float |
f64 (or f32) |
Data Types |
str |
String (owned, growable) and &str (a borrowed view of text) |
Strings |
f-strings: f"Hi {name}" |
format!("Hi {name}") and println!("Hi {name}") |
Output |
print(repr(x)) / __repr__ |
println!("{x:?}") with #[derive(Debug)] |
Output, Common Traits |
__str__ |
Implement Display (then .to_string() works too) |
Common Traits |
__eq__, __hash__, __lt__ |
Derive PartialEq, Eq, Hash, PartialOrd, Ord |
Common Traits |
None, Optional[T] |
Option<T>: either Some(value) or None |
Option |
x or default |
x.unwrap_or(default) |
Option |
raise, try/except |
Result<T, E> + the ? operator |
Result, Custom Errors |
An exception for a real bug (assert, IndexError) |
panic! (stops the thread) |
Panic |
class with __init__ and methods |
struct for the data + an impl block for the methods; new is a normal function |
Structs |
@dataclass |
struct with #[derive(Debug, Clone, PartialEq)] |
Structs |
self |
&self (read), &mut self (change), or self (take ownership) |
Structs |
| Inheritance | No inheritance. Use composition (a struct inside a struct) + traits with default methods | Traits, Design Patterns |
Duck typing, Protocol, ABC |
trait |
Traits |
| A list of objects of different classes | Vec<Box<dyn Trait>> (a trait object) |
Trait Objects |
TypeVar / generic functions |
Generics with trait bounds: fn largest<T: Ord>(items: &[T]) |
Generics |
Enum class |
enum, where each variant can carry its own data |
Enums |
match / case (3.10+) |
match (the compiler checks you covered every case) |
match, Pattern Matching |
if my_list: (truthiness) |
Be explicit: if !my_list.is_empty(). Only bool goes in an if |
if/else |
for i in range(10) |
for i in 0..10 |
for loops |
enumerate(xs), zip(a, b) |
xs.iter().enumerate(), a.iter().zip(b.iter()) |
Iterators |
lambda x: x + 1 |
Closures: |x| x + 1 (and they can span many lines) |
Closures |
List comprehensions, map, filter |
Iterator adapters: filter, map, then collect() |
Iterators |
Generators and yield |
Your own type that implements Iterator, or std::iter::from_fn |
Custom Iterators |
with open(...) as f: |
Nothing to write: files and locks close when their owner goes out of scope (Drop) |
Scope, File I/O |
Default arguments, *args, **kwargs |
Not supported. Use Option parameters, slices (&[T]), a struct with Default, or a builder |
Functions, Design Patterns |
| Decorators | No direct equivalent. Wrap with closures, or use attribute macros (like #[tokio::main]) |
Closures, Procedural Macros |
import and modules |
mod and use |
Modules |
| Package on PyPI | Crate on crates.io | Cargo & Crates |
if __name__ == "__main__": |
fn main() |
Get Started |
threading (with the GIL) |
std::thread: real parallel threads |
Concurrency |
multiprocessing, concurrent.futures |
Threads, or the rayon crate for parallel iterators |
Concurrency, Shared State |
asyncio, async def, await |
async fn and .await, with a runtime like tokio |
Async/Await |
7 // 2, 2 ** 10 |
7 / 2 (integer division on integers), 2_i64.pow(10) |
Operators |
int("42"), str(42) |
"42".parse::<i32>() (returns a Result), 42.to_string() |
Type Casting, Conversions |
Collections
Section titled “Collections”Python’s built-in collections all have a home in Rust’s standard library. The biggest differences: a Rust collection holds one type (a Vec<i32> holds only i32s), and a HashMap doesn’t remember insertion order.
| Python | Rust | Learn more |
|---|---|---|
list |
Vec<T> |
Vectors |
tuple |
Tuples (i32, String), or a small struct with named fields |
Tuples |
dict |
HashMap<K, V> (no fixed order!) |
HashMaps |
dict where you need sorted keys |
BTreeMap<K, V> |
HashMaps |
dict where you need insertion order |
The indexmap crate |
|
set, frozenset |
HashSet<T>, BTreeSet<T> |
HashSet |
collections.Counter, defaultdict |
HashMap with the entry API: *map.entry(k).or_insert(0) += 1 |
HashMaps |
collections.deque |
VecDeque<T> |
VecDeque & BinaryHeap |
heapq (smallest first) |
BinaryHeap<T> (largest first; wrap items in Reverse for smallest first) |
VecDeque & BinaryHeap |
namedtuple |
struct |
Structs |
bytes, bytearray |
&[u8], Vec<u8> |
Slices |
Slicing: xs[1:3] |
Slices: &xs[1..3] (no negative indexes; use xs.len() - 1) |
Slices |
Tooling: pip to cargo
Section titled “Tooling: pip to cargo”This is where many Python developers relax a little. Rust has one official tool, Cargo, that does the jobs of pip, venv, setuptools, pytest, and more. There is no “which packaging tool should we use?” debate.
| Python | Rust | Notes |
|---|---|---|
python interpreter |
rustc compiler, usually called through cargo |
You compile first, then run a native binary. See Get Started |
| pyenv | rustup (and a rust-toolchain.toml file to pin a version) |
See Rust Tools & Setup |
| pip, poetry, uv | cargo |
Builds, runs, tests, adds packages, publishes. See Cargo & Crates |
pip install requests |
cargo add reqwest |
Adds the crate to Cargo.toml for this project only |
pyproject.toml / requirements.txt |
Cargo.toml |
|
poetry.lock / uv.lock |
Cargo.lock |
Created for you |
| venv / virtualenv | Not needed | Every project has its own dependencies and builds into its own target/ folder |
| PyPI | crates.io | Every published crate’s API docs are on docs.rs |
python main.py |
cargo run (cargo run --release for full speed) |
Debug builds are much slower at runtime; measure speed in release mode |
pipx install ruff |
cargo install ripgrep |
Installs a command-line tool |
| PyInstaller | cargo build --release |
You get one native binary; users don’t need Rust installed |
| black | cargo fmt |
One standard style for everyone |
| ruff, pylint, flake8 | cargo clippy |
Hundreds of helpful lints, official |
| mypy, pyright | The compiler itself | Type checking is not optional and not a separate step |
| pytest, unittest | Built-in #[test] and cargo test (cargo nextest is a popular faster runner) |
See Testing |
pytest fixtures and parametrize |
rstest |
|
| hypothesis | proptest |
Property-based testing. See Testing Beyond the Basics |
unittest.mock |
mockall, or hand-written fakes that implement your trait |
|
| timeit, pytest-benchmark | criterion |
See Performance |
| cProfile, py-spy | cargo flamegraph, perf, or any native profiler |
|
| Docstrings + Sphinx | /// doc comments in Markdown + cargo doc |
Code examples in docs are run as tests. See Documentation |
| The Python REPL, Jupyter | The Rust Playground; evcxr_repl and evcxr_jupyter |
Rust has no built-in REPL. Small cargo run projects are the usual way to experiment |
| VS Code + Pylance, PyCharm | VS Code + rust-analyzer, or JetBrains RustRover | rust-analyzer gives completion, go-to-definition, and inline errors |
Here is a small Cargo.toml, so you can compare it with a pyproject.toml:
[package]name = "weather-cli"version = "0.1.0"edition = "2021"
[dependencies]reqwest = { version = "0.13", features = ["blocking", "json"] }serde = { version = "1", features = ["derive"] }clap = { version = "4", features = ["derive"] }Libraries and frameworks: “What do we use instead of…?”
Section titled “Libraries and frameworks: “What do we use instead of…?””“I use requests, FastAPI, and pandas. What’s the Rust version?” Here are the closest equivalents. Python’s ecosystem is much older and bigger, so for some areas (data science especially) Rust has fewer and younger options.
Web and networking
Section titled “Web and networking”| Python | Rust | Notes |
|---|---|---|
| Flask, FastAPI | axum (we build one in Project: Web API), actix-web | Handlers are plain async fns; request and response types come from your structs, a bit like FastAPI with pydantic |
| Django | No full equivalent. Loco (loco-rs) is a batteries-included framework; most teams combine axum + an ORM + a template engine |
No built-in admin site |
| requests, httpx | reqwest (async, with an optional blocking client), ureq (simple and blocking) |
See Networking |
| aiohttp | reqwest + tokio |
|
| Jinja2 | minijinja (by Jinja’s own author), tera, askama (templates checked at compile time) |
|
| BeautifulSoup | scraper |
Data and databases
Section titled “Data and databases”| Python | Rust | Notes |
|---|---|---|
| SQLAlchemy ORM | Diesel (queries type-checked at compile time), SeaORM (async, entity-style) | Rust Databases shows both with working code |
| psycopg, raw SQL | sqlx: plain SQL, checked against your real database at compile time | Async, very popular, not an ORM |
| sqlite3 | rusqlite |
|
| redis-py | redis |
|
| Celery | apalis for job queues; tokio::spawn for in-process background tasks |
Data science and machine learning
Section titled “Data science and machine learning”| Python | Rust | Notes |
|---|---|---|
| pandas | Polars | Written in Rust; its Python API, which you may already know, is built on the same Rust library |
| NumPy | ndarray |
N-dimensional arrays with slicing and broadcasting; far fewer extras than NumPy |
| scikit-learn | linfa, smartcore |
Fewer algorithms and much smaller communities |
| PyTorch | candle (crate candle-core), burn |
Good for running models; most training still happens in Python |
| matplotlib | plotters |
|
csv module |
csv crate + serde |
Everyday building blocks
Section titled “Everyday building blocks”| Python | Rust | Notes |
|---|---|---|
json |
serde + serde_json |
The standard; also does TOML, YAML, and more. See Serde & JSON |
| pydantic | serde for parsing + validator or garde for validation rules |
Your struct is the schema |
| argparse, click, typer | clap |
Define your arguments as a struct. See Project: CLI with clap |
logging |
tracing, or log + env_logger |
See Logging |
re |
regex |
No look-around or backreferences, but guaranteed linear time; fancy-regex has them. See Regex |
datetime |
chrono or jiff |
See Date & Time |
random |
rand |
See Random Numbers |
asyncio |
tokio |
See Async/Await |
multiprocessing.Pool.map |
rayon: change .iter() to .par_iter() |
|
itertools |
Most of it is built into Iterator; the itertools crate adds the rest |
|
os, pathlib, subprocess |
std::env, std::path, std::fs, std::process |
See File I/O, Environment & Processes |
Side by side: a class becomes a struct + impl
Section titled “Side by side: a class becomes a struct + impl”Here is a small Python class:
class Account: def __init__(self, owner, balance=0): self.owner = owner self.balance = balance
def deposit(self, amount): self.balance += amount
def __str__(self): return f"{self.owner} has {self.balance}"
account = Account("Ada", 100)account.deposit(50)print(account)And the same thing in Rust:
use std::fmt;
struct Account { owner: String, balance: i64,}
impl Account { // `new` is just a convention, not a keyword. No default arguments, // so a caller always passes both values. fn new(owner: &str, balance: i64) -> Self { Account { owner: owner.to_string(), balance } }
// `&mut self`: this method changes the account. fn deposit(&mut self, amount: i64) { self.balance += amount; }}
// This is Rust's __str__.impl fmt::Display for Account { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{} has {}", self.owner, self.balance) }}
fn main() { let mut account = Account::new("Ada", 100); account.deposit(50); println!("{account}");}Output:
Ada has 150A few things are different. The fields are declared up front with their types, so you can’t add a new attribute later the way Python lets you write account.nickname = "A". Data (the struct) and behavior (the impl) live in separate blocks. Every method says whether it only reads (&self) or changes (&mut self) the value, and because deposit changes the account, the variable must be declared let mut. Rust has no default arguments; if you want an account that starts at zero, you’d add a second function, like fn empty(owner: &str) -> Self.
Side by side: duck typing becomes traits
Section titled “Side by side: duck typing becomes traits”Python doesn’t care what class an object is, only that it has the right method:
class Duck: def speak(self): return "Quack"
class Robot: def speak(self): return "Beep boop"
def introduce(thing): print(f"It says: {thing.speak()}")
for thing in [Duck(), Robot()]: introduce(thing)
introduce(42) # AttributeError, but only when this line runsIn Rust, “has a speak method” is written down as a trait, and each type opts in with an impl block:
trait Speak { fn speak(&self) -> String;}
struct Duck;struct Robot;
impl Speak for Duck { fn speak(&self) -> String { "Quack".to_string() }}
impl Speak for Robot { fn speak(&self) -> String { "Beep boop".to_string() }}
// Accepts any type that implements Speak.fn introduce(thing: &dyn Speak) { println!("It says: {}", thing.speak());}
fn main() { let things: Vec<Box<dyn Speak>> = vec![Box::new(Duck), Box::new(Robot)]; for thing in &things { introduce(thing.as_ref()); }
// introduce(&42); // compile error: `{integer}` doesn't implement Speak}Output:
It says: QuackIt says: Beep boopIt’s still duck typing in spirit, but checked by the compiler: introduce(&42) is an error before the program ever runs, not an AttributeError in production. If you’ve used typing.Protocol, traits will feel natural. A list of mixed types needs Box<dyn Speak>, because Rust has to know how to call the method on each item (see Trait Objects). And you can implement your own trait for types you didn’t write, like String or i32.
Side by side: None becomes Option
Section titled “Side by side: None becomes Option”In Python, any value can secretly be None, and you find out when you use it:
def find_email(user_id): return emails.get(user_id) # returns None if missing
print(find_email(1).upper())print(find_email(3).upper()) # AttributeError: 'NoneType' object has no attribute 'upper'In Rust, a value that might be missing has the type Option<T>. You can’t use it as a plain value until you’ve handled the None case:
fn find_email(user_id: u32) -> Option<String> { match user_id { _ => None, }}
fn main() { for id in [1, 3] { match find_email(id) { Some(email) => println!("{id}: {}", email.to_uppercase()), None => println!("{id}: no email on file"), } }
// Like Python's `find_email(3) or "unknown"`. let email = find_email(3).unwrap_or("unknown".to_string()); println!("{email}");
// Change the value inside, if there is one. let upper = find_email(2).map(|e| e.to_uppercase()); println!("{upper:?}");}Output:
3: no email on fileunknownSome("[email protected]")The Rust compiler won’t let you call .to_uppercase() on an Option<String>, so the “'NoneType' object has no attribute” error simply can’t happen. A Rust String is always a real string. Learn the helper methods on the Option page, and the short form if let Some(email) = ... on if let.
Side by side: exceptions become Result and ?
Section titled “Side by side: exceptions become Result and ?”In Python, int() raises an exception, and you catch it somewhere up the call stack:
def parse_port(text): port = int(text) # may raise ValueError if not 1 <= port <= 65535: raise ValueError(f"port {port} is out of range") return port
for text in ["8080", "http", "70000"]: try: print(f"port {parse_port(text)}") except ValueError as e: print(f"error: {e}")In Rust, a function that can fail says so in its return type. The ? operator means “if this failed, return the error to my caller right now”. It does the job of letting an exception bubble up, but you can see it in the code:
fn parse_port(text: &str) -> Result<u32, String> { let port: u32 = text .parse() .map_err(|e| format!("{text:?} is not a number ({e})"))?;
if !(1..=65535).contains(&port) { return Err(format!("port {port} is out of range")); } Ok(port)}
fn main() { for text in ["8080", "http", "70000"] { match parse_port(text) { Ok(port) => println!("port {port}"), Err(e) => println!("error: {e}"), } }}Output:
port 8080error: "http" is not a number (invalid digit found in string)error: port 70000 is out of rangeThe big difference: in Python, any function might raise, and nothing in its signature tells you. In Rust, Result is part of the signature, so callers can’t forget about it. String is fine as an error type while you learn; real projects use their own error enums (Custom Errors) or the anyhow crate, which feels a lot like “raise any exception” (anyhow & thiserror).
Side by side: comprehensions and dicts become iterators and HashMap
Section titled “Side by side: comprehensions and dicts become iterators and HashMap”List comprehensions and dict counting are everyday Python:
text = "the cat sat on the mat the end"
loud = [w.upper() for w in text.split() if len(w) == 3]print(loud)
counts = {}for word in text.split(): counts[word] = counts.get(word, 0) + 1
top = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[:3]for word, n in top: print(f"{word}: {n}")In Rust, a comprehension becomes an iterator chain ending in collect(), and the dict becomes a HashMap:
use std::collections::HashMap;
fn main() { let text = "the cat sat on the mat the end";
// [w.upper() for w in text.split() if len(w) == 3] let loud: Vec<String> = text .split_whitespace() .filter(|w| w.len() == 3) .map(|w| w.to_uppercase()) .collect(); println!("{loud:?}");
// counts[word] = counts.get(word, 0) + 1 let mut counts: HashMap<&str, u32> = HashMap::new(); for word in text.split_whitespace() { *counts.entry(word).or_insert(0) += 1; }
// Most common first; ties in alphabetical order. let mut top: Vec<(&str, u32)> = counts.into_iter().collect(); top.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0))); for (word, n) in top.iter().take(3) { println!("{word}: {n}"); }}Output:
["THE", "CAT", "SAT", "THE", "MAT", "THE", "END"]the: 3cat: 1end: 1The chain reads left to right instead of inside out, but the idea is the same. Like a generator expression, it’s lazy: nothing happens until collect(), sum(), count(), or a for loop pulls the values through. collect() needs to know what to build, which is why loud has the type Vec<String>. And note the sorting step: a Python dict remembers insertion order, but a Rust HashMap doesn’t, so we always sort before printing. Sorting is covered on the Sorting page.
Side by side: the GIL vs real threads
Section titled “Side by side: the GIL vs real threads”In standard CPython, the Global Interpreter Lock (GIL) lets only one thread run Python bytecode at a time. Threads help when you wait for the network, but CPU-heavy work like this doesn’t get faster with more threads, so Python developers reach for multiprocessing instead:
from concurrent.futures import ThreadPoolExecutor
def is_prime(n): if n < 2: return False d = 2 while d * d <= n: if n % d == 0: return False d += 1 return True
def count_primes(lo, hi): return sum(1 for n in range(lo, hi) if is_prime(n))
ranges = [(0, 25_000), (25_000, 50_000), (50_000, 75_000), (75_000, 100_000)]with ThreadPoolExecutor(4) as pool: counts = list(pool.map(lambda r: count_primes(*r), ranges))print("total:", sum(counts))Rust has no GIL. Each thread::spawn is a real operating-system thread, and they run on separate CPU cores at the same time:
use std::thread;
fn is_prime(n: u32) -> bool { if n < 2 { return false; } let mut d = 2; while d * d <= n { if n % d == 0 { return false; } d += 1; } true}
fn count_primes(lo: u32, hi: u32) -> usize { (lo..hi).filter(|&n| is_prime(n)).count()}
fn main() { let ranges = [(0, 25_000), (25_000, 50_000), (50_000, 75_000), (75_000, 100_000)];
// Each range gets its own OS thread. They really run at the same time. let counts: Vec<usize> = thread::scope(|s| { let handles: Vec<_> = ranges .iter() .map(|&(lo, hi)| s.spawn(move || count_primes(lo, hi))) .collect(); handles.into_iter().map(|h| h.join().unwrap()).collect() });
for ((lo, hi), count) in ranges.iter().zip(&counts) { println!("{lo}..{hi}: {count} primes"); } println!("total: {}", counts.iter().sum::<usize>());}Output:
0..25000: 2762 primes25000..50000: 2371 primes50000..75000: 2260 primes75000..100000: 2199 primestotal: 9592thread::scope waits for every thread before it returns, so the threads may safely borrow local data. Without a GIL, you might worry about two threads changing the same value at once. Rust handles that at compile time: shared data that can change has to be wrapped in something like Arc<Mutex<T>>, or the program won’t compile (see Shared State). In real projects, the rayon crate makes this even shorter, a bit like multiprocessing.Pool.map but with threads:
use rayon::prelude::*;
let total = (0..100_000u32).into_par_iter().filter(|&n| is_prime(n)).count();Python is changing here too: Python 3.13 added an optional “free-threaded” build without the GIL. But the standard CPython that most people install and deploy still has the GIL, and many C extensions aren’t ready for free threading yet.
Writing Python extensions in Rust (PyO3 + maturin)
Section titled “Writing Python extensions in Rust (PyO3 + maturin)”You don’t have to leave Python to use Rust. PyO3 lets you write a Python module in Rust, and maturin builds it into a normal wheel that pip can install. This is how Polars, pydantic-core, and many other fast Python libraries are made.
Set up a project (inside an activated virtual environment):
pip install maturinmaturin new --bindings pyo3 fastmathcd fastmathmaturin writes the Cargo.toml and pyproject.toml for you. Then put your Rust functions in src/lib.rs:
use pyo3::prelude::*;
/// A Python module written in Rust.#[pymodule]mod fastmath { use pyo3::prelude::*;
/// Add up the squares of a list of numbers. #[pyfunction] fn sum_of_squares(numbers: Vec<i64>) -> i64 { numbers.iter().map(|n| n * n).sum() }
/// Count the words in a piece of text. #[pyfunction] fn word_count(text: &str) -> usize { text.split_whitespace().count() }}Build it into your virtual environment with maturin develop (add --release when you care about speed), and use it from Python like any other module:
import fastmath
print(fastmath.sum_of_squares([1, 2, 3, 4])) # 30print(fastmath.word_count("the quick brown fox")) # 4fastmath.sum_of_squares(["a"]) # TypeError: 'str' object cannot be interpreted as an integerPyO3 converts types for you: a Python list of ints becomes a Vec<i64>, a str becomes a &str, and the doc comments become Python docstrings. Wrong types turn into a normal Python TypeError. When you’re ready to share it, maturin build --release makes a wheel, and maturin publish uploads it to PyPI. A good rule: measure first, then move only the hot loop to Rust. Crossing between Python and Rust has a small cost, so one call that does a lot of work beats a million tiny calls.
Things that will trip you up as a Python developer
Section titled “Things that will trip you up as a Python developer”Everyone coming from Python hits the same bumps. Knowing them in advance helps.
Assignment moves the value
Section titled “Assignment moves the value”In Python, b = a gives you two names for the same list. In Rust, let b = a; moves ownership of a Vec or String into b, and a can’t be used anymore:
fn main() { let a = vec![1, 2, 3]; let mut b = a; b.push(4); println!("{a:?}");}error[E0382]: borrow of moved value: `a` --> src/main.rs:5:16 |2 | let a = vec![1, 2, 3]; | - move occurs because `a` has type `Vec<i32>`, which does not implement the `Copy` trait3 | let mut b = a; | - value moved here4 | b.push(4);5 | println!("{a:?}"); | ^ value borrowed here after move |help: consider cloning the value if the performance cost is acceptable |3 | let mut b = a.clone(); | ++++++++The fix is to borrow (let b = &a;, if you only need to read) or to make a real copy on purpose (a.clone()). This one rule, ownership, is the most important new idea for you. Read that page and Borrowing slowly.
The rest of the list
Section titled “The rest of the list”- Integers have a size. Python’s
intnever overflows. A Rusti32stops at 2,147,483,647; going past it panics in a debug build. Picki64oru64for big numbers, and see Integer Overflow. - Division and remainder work differently.
7 / 2is3in Rust, because both sides are integers (Python’s//). Write7.0 / 2.0to get3.5. Also,-7 % 3is-1in Rust but2in Python; use(-7_i32).rem_euclid(3)to get Python’s answer. - No mixing number types.
let x: f64 = 2.5 * 3;doesn’t compile, because3is an integer. Write3.0, or convert withas f64(see Type Casting). - You can’t index a string.
word[0]is a compile error, because Rust strings are UTF-8 and a “character” can be several bytes. Useword.chars().next()orword.chars().nth(i). See Strings. Stringvs&str. Python has onestrtype. Rust has two:Stringowns its text,&strborrows it. Functions usually take&strand structs usually storeString.- Variables are immutable by default. Write
let mutwhen a value needs to change. The compiler will tell you when you forgot. - Braces and semicolons. Indentation doesn’t matter to Rust;
{ }does. And the last line of a function without a semicolon is its return value:fn double(x: i32) -> i32 { x * 2 }. - Every
ifandmatchmust be complete and type-correct. No truthiness:if countdoesn’t compile, you writeif count > 0. Amatchmust cover every case. - A HashMap has no order. Python’s
dictremembers insertion order; Rust’sHashMapdoesn’t. Sort before printing, use aBTreeMap, or add theindexmapcrate. - There’s a compile step. No REPL, and a clean build of a project with many crates takes a while. Use
cargo checkwhile you edit (it skips code generation and is much faster), andcargo run --releasewhen you measure speed. - The borrow checker will push back. Patterns that are normal in Python, like many objects all holding and changing one shared object, or objects pointing back at their parent, need smart pointers or a different design. Expect a few frustrating weeks. It gets much easier.
A learning path for Python developers
Section titled “A learning path for Python developers”You don’t need to read this site in order. As a Python developer, this route spends time where Rust is most different from what you know:
- Get Started and Rust Tools & Setup: install Rust and run
cargo new. - Variables, Data Types, and Integer Overflow: types are the first new habit.
- Functions, if/else, and Loops: quick reads; notice the expressions and the missing semicolons.
- Ownership, then Borrowing, then Slices: the most important pages for you. Take your time.
- Strings:
Stringvs&str, and why you can’t index a string. - Structs and Enums, then match and Pattern Matching.
- Option and Result, then Custom Errors.
- Vectors, HashMaps, and HashSet.
- Closures and Iterators: your comprehensions and generators.
- Traits, Generics, Common Traits, and Trait Objects.
- Modules, Cargo & Crates, and Testing.
- Lifetimes and Smart Pointers.
- Concurrency, Shared State, and Async/Await.
- Serde & JSON, then build a CLI with clap or a Web API.
Practice
Section titled “Practice”Both exercises give you a Python snippet. Port it to Rust.
1. Port a comprehension and a dict
Section titled “1. Port a comprehension and a dict”Port this Python code. Hint: to print the names in order, collect them into a Vec<&str>, call .sort(), then .join(", "). For the average, convert with as f64 before dividing.
scores = {"Ada": 92, "Bob": 47, "Cy": 75, "Dee": 38}
passed = sorted(name for name, score in scores.items() if score >= 50)print("passed:", ", ".join(passed))
odd_squares = [n * n for n in range(1, 10) if n % 2 == 1]print(odd_squares)
average = sum(scores.values()) / len(scores)print(f"average: {average:.1f}")Show solution
use std::collections::HashMap;
fn main() { let scores: HashMap<&str, u32> = HashMap::from([("Ada", 92), ("Bob", 47), ("Cy", 75), ("Dee", 38)]);
let mut passed: Vec<&str> = scores .iter() .filter(|(_, score)| **score >= 50) .map(|(name, _)| *name) .collect(); passed.sort(); println!("passed: {}", passed.join(", "));
let odd_squares: Vec<u32> = (1..10).filter(|n| n % 2 == 1).map(|n| n * n).collect(); println!("{odd_squares:?}");
let total: u32 = scores.values().sum(); let average = total as f64 / scores.len() as f64; println!("average: {average:.1}");}Output:
passed: Ada, Cy[1, 9, 25, 49, 81]average: 63.0Iterating over a HashMap gives you pairs of references, which is why you see **score and *name: they follow the references back to the values. The sort() is not optional here: without it, the names could come out in any order. And total as f64 is needed because Rust never turns an integer into a float behind your back.
2. Port try/except to Result
Section titled “2. Port try/except to Result”Port this Python code. Each line should be name: age. Hint: line.split_once(':') returns an Option<(&str, &str)>; turn it into a Result with .ok_or(...), then use ?. Make your function return Result<(String, u32), String>.
def parse_line(line): if ":" not in line: raise ValueError("missing ':'") name, age = line.split(":", 1) try: return name.strip(), int(age.strip()) except ValueError as e: raise ValueError(f"bad age: {e}")
ages = {}for line in ["Ada: 36", "Grace 45", "Linus: forty"]: try: name, age = parse_line(line) ages[name] = age print(f"ok: {name} is {age}") except ValueError as e: print(f"skipped {line!r}: {e}")print(len(ages), "saved")Show solution
use std::collections::HashMap;
fn parse_line(line: &str) -> Result<(String, u32), String> { let (name, age) = line.split_once(':').ok_or("missing ':'")?; let age: u32 = age.trim().parse().map_err(|e| format!("bad age: {e}"))?; Ok((name.trim().to_string(), age))}
fn main() { let mut ages = HashMap::new();
for line in ["Ada: 36", "Grace 45", "Linus: forty"] { match parse_line(line) { Ok((name, age)) => { println!("ok: {name} is {age}"); ages.insert(name, age); } Err(e) => println!("skipped {line:?}: {e}"), } }
println!("{} saved", ages.len());}Output:
ok: Ada is 36skipped "Grace 45": missing ':'skipped "Linus: forty": bad age: invalid digit found in string1 savedBoth raise statements became Err values, and the try/except became a match. The ? after ok_or("missing ':'") works even though the error is a &str and the function returns String errors, because ? converts the error with From. Notice the order inside Ok: we print first, then insert moves name into the map. Swap those two lines and the compiler will tell you name was moved.
- Much of Python carries over: loops over iterators, tuples, closures,
match, and comprehension-style data processing (as iterator chains). - The big shifts are static types checked before running, ownership instead of a garbage collector,
Optioninstead ofNone, andResult+?instead of exceptions. - Classes become a
struct+impl, duck typing becomes traits, andwithblocks become automaticDrop. AHashMaphas no order, and integers have a fixed size. - Rust has no GIL: threads run in parallel, and the compiler prevents data races.
- Cargo replaces pip, venv, and friends;
cargo fmt,cargo clippy, andcargo testreplace black, ruff, and pytest. - You can mix both languages: PyO3 and maturin turn Rust code into a Python module.
Quick check
Section titled “Quick check”Quick check
1. What does Rust use instead of Python's None?
2. In Python, b = a gives two names for the same list. What does let b = a; do with a Vec in Rust?
3. What does 7 / 2 evaluate to in Rust, where both numbers are integers?
4. You want to speed up one slow function in a Python project with Rust. Which tools do you use?
Score: 0 / 4
Read more
Section titled “Read more”- The Rust Programming Language: the official book. Chapters 4 (ownership), 6 (enums and
Option), 9 (errors), and 13 (closures and iterators) matter most for Python developers. - PyO3 user guide: everything about calling Rust from Python (and Python from Rust).
- Maturin user guide: building, testing, and publishing Rust-powered Python packages.
- Rust by Example: short runnable examples, good when you learn best by reading code.
- Are We Learning Yet?: an overview of Rust’s machine-learning and data crates.