Skip to content

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 None you 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 import it 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.

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

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

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.

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
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
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
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 150
▶ Try it Yourself

A 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.

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 runs

In 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: Quack
It says: Beep boop
▶ Try it Yourself

It’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.

In Python, any value can secretly be None, and you find out when you use it:

def find_email(user_id):
emails = {1: "[email protected]", 2: "[email protected]"}
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 {
1 => Some("[email protected]".to_string()),
2 => Some("[email protected]".to_string()),
_ => 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 file
unknown
▶ Try it Yourself

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 8080
error: "http" is not a number (invalid digit found in string)
error: port 70000 is out of range
▶ Try it Yourself

The 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: 3
cat: 1
end: 1
▶ Try it Yourself

The 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.

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 primes
25000..50000: 2371 primes
50000..75000: 2260 primes
75000..100000: 2199 primes
total: 9592
▶ Try it Yourself

thread::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):

Terminal window
pip install maturin
maturin new --bindings pyo3 fastmath
cd fastmath

maturin 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])) # 30
print(fastmath.word_count("the quick brown fox")) # 4
fastmath.sum_of_squares(["a"]) # TypeError: 'str' object cannot be interpreted as an integer

PyO3 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.

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` trait
3 | let mut b = a;
| - value moved here
4 | 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.

  • Integers have a size. Python’s int never overflows. A Rust i32 stops at 2,147,483,647; going past it panics in a debug build. Pick i64 or u64 for big numbers, and see Integer Overflow.
  • Division and remainder work differently. 7 / 2 is 3 in Rust, because both sides are integers (Python’s //). Write 7.0 / 2.0 to get 3.5. Also, -7 % 3 is -1 in Rust but 2 in 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, because 3 is an integer. Write 3.0, or convert with as 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. Use word.chars().next() or word.chars().nth(i). See Strings.
  • String vs &str. Python has one str type. Rust has two: String owns its text, &str borrows it. Functions usually take &str and structs usually store String.
  • Variables are immutable by default. Write let mut when 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 if and match must be complete and type-correct. No truthiness: if count doesn’t compile, you write if count > 0. A match must cover every case.
  • A HashMap has no order. Python’s dict remembers insertion order; Rust’s HashMap doesn’t. Sort before printing, use a BTreeMap, or add the indexmap crate.
  • There’s a compile step. No REPL, and a clean build of a project with many crates takes a while. Use cargo check while you edit (it skips code generation and is much faster), and cargo run --release when 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.

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:

  1. Get Started and Rust Tools & Setup: install Rust and run cargo new.
  2. Variables, Data Types, and Integer Overflow: types are the first new habit.
  3. Functions, if/else, and Loops: quick reads; notice the expressions and the missing semicolons.
  4. Ownership, then Borrowing, then Slices: the most important pages for you. Take your time.
  5. Strings: String vs &str, and why you can’t index a string.
  6. Structs and Enums, then match and Pattern Matching.
  7. Option and Result, then Custom Errors.
  8. Vectors, HashMaps, and HashSet.
  9. Closures and Iterators: your comprehensions and generators.
  10. Traits, Generics, Common Traits, and Trait Objects.
  11. Modules, Cargo & Crates, and Testing.
  12. Lifetimes and Smart Pointers.
  13. Concurrency, Shared State, and Async/Await.
  14. Serde & JSON, then build a CLI with clap or a Web API.

Both exercises give you a Python snippet. Port it to Rust.

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.0
▶ Try it Yourself

Iterating 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.

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 36
skipped "Grace 45": missing ':'
skipped "Linus: forty": bad age: invalid digit found in string
1 saved
▶ Try it Yourself

Both 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, Option instead of None, and Result + ? instead of exceptions.
  • Classes become a struct + impl, duck typing becomes traits, and with blocks become automatic Drop. A HashMap has 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, and cargo test replace black, ruff, and pytest.
  • You can mix both languages: PyO3 and maturin turn Rust code into a Python module.

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

  • 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.