Skip to content
rusthacks.dev
Rust

Rust for Python Developers a Practical Translation Guide

Python developers already know most of what Rust asks for. This guide maps each Python habit to its Rust equivalent and names the three shifts that cause most of the early compiler errors.

18 min read
Summarize in AI
Flat illustration for the post “Rust for Python Developers a Practical Translation Guide”, composed around tooling.

A Python developer reads Rust and recognises almost everything: iterators, closures, pattern matching, comprehensions in spirit, a package manager that just works. Then the compiler rejects a program that would have run happily in Python, and the recognition turns into suspicion. The code is rarely wrong in a way Python would care about. It is wrong in a way Python never asks you to think about: who owns this value, and for how long.

This guide translates Python habits into Rust ones, shows the errors you will actually meet with the compiler's own words, and measures what you get in return. Every Rust example below was compiled and run on Rust 1.99 with edition 2024 and the standard library only, and the Python comparisons ran on Python 3.14.6. The timings come from an Apple M1 Max, so read them as ratios rather than promises.

The three differences that explain most of it

Almost every early surprise is one of three differences wearing a different costume.

  1. A value has one owner. Python lets many names point at one object and frees it when the last name goes away. Rust names one owner at compile time and frees the value when that owner leaves scope. Everything else (moves, borrows, lifetimes) follows from that rule.
  2. Types are checked before the program runs. A Python type hint is a note for a tool you may or may not run. A Rust type is a fact the compiler enforces, and a program with a type error does not produce a binary.
  3. Errors are values. A function that can fail says so in its return type, and the caller has to do something about it. Nothing unwinds through your code on its own.

The rest of this post takes the Python constructs you use daily and shows the Rust that replaces each, in the order you will need them. If you want the longer treatment of the ownership rule, ownership explained covers it from first principles, and why Rust feels hard is honest about the parts that stay hard.

Type hints become the program

Here is the same small function in both languages.

python
def total(prices: list[float], tax: float) -> float:
    return sum(prices) * (1 + tax)
rust
fn total(prices: &[f64], tax: f64) -> f64 {
    prices.iter().sum::<f64>() * (1.0 + tax)
}

The parameter &[f64] is a borrowed slice: a read-only view of a run of floats that lives somewhere else. It accepts a Vec<f64>, a fixed-size array, or part of either, which is the same flexibility list[float] gives a Python caller. The ::<f64> after sum tells the compiler which type to add up into, because sum can produce more than one numeric type and a bare call would be ambiguous. You write annotations at function boundaries and let inference fill in the locals, which is how a well-typed Python codebase already reads.

Two number differences are worth knowing before they bite. A Python int grows without limit; a Rust integer has a fixed width such as i32 or u64, and arithmetic past that width panics in a debug build and wraps in a release build (integer overflow covers the choices you have). And Rust does not convert between numeric types behind your back: adding an i32 to an f64 is a compile error, and you write the conversion with as or From so the loss, if any, is visible.

Collections, strings and the &str split

The built-in containers map across directly, with one name change each.

PythonRustNote
listVec<T>One element type per vector, fixed at compile time
dictHashMap<K, V>Unordered; use BTreeMap for sorted keys
setHashSet<T>Same set algebra through methods
tuple(A, B)Fixed arity, mixed types
None or a valueOption<T>The compiler makes you handle the empty case
strString or &strOwned text versus a borrowed view

The string row is the one that confuses people first, because Python has one string type and Rust has two. A String owns its bytes and can grow. A &str is a borrowed view into text that something else owns, whether that is a String, a literal baked into the binary, or a slice of a larger buffer. A function that only reads text takes &str; a function that keeps the text takes String. The deeper version of that rule lives in String versus str. Rust strings are always UTF-8, so s[3] is not allowed: byte three might sit in the middle of a character. You iterate with .chars() or .char_indices() instead.

The next program counts words, which is the kind of task a Python developer writes with Counter in four lines. It is a complete file; it compiles and runs as pasted.

rust
use std::collections::HashMap;

fn word_counts(text: &str) -> HashMap<&str, usize> {
    let mut counts = HashMap::new();
    for word in text.split_whitespace() {
        *counts.entry(word).or_insert(0) += 1;
    }
    counts
}

fn main() {
    let text = String::from("the cat saw the dog and the dog saw the cat");
    let counts = word_counts(&text);

    let mut ranked: Vec<(&str, usize)> = counts.into_iter().collect();
    ranked.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));

    for (word, n) in ranked.iter().take(3) {
        println!("{word:<5} {n}");
    }
}

Running it prints the three most frequent words.

Text
the   4
cat   2
dog   2

Read it from the top. word_counts takes a &str and returns a HashMap<&str, usize>, so the keys are not copies of the words but borrows into the original text. That is why no allocation happens per word, and it is also the reason for the type signature: the map cannot outlive the text it points into, and the compiler enforces that without a single lifetime annotation, because with one input reference the output lifetime is inferred. The entry(word).or_insert(0) call is the equivalent of setdefault, and the leading * dereferences the &mut usize it hands back so that += 1 changes the stored count rather than a temporary.

In main, the map is drained into a Vec of pairs because a HashMap has no order to sort. The comparator sorts by count descending and breaks ties alphabetically; without the tie-break, the order of equal counts would change between runs, because Rust seeds its hash function randomly per process. Python dictionaries keep insertion order, so this is a habit worth acquiring: never rely on HashMap iteration order. A change you might make here is to swap the map for a BTreeMap if you want sorted output for free, at the cost of slower inserts (collections compared has the numbers).

Ownership: the error you will meet first

Python lets you pass a string to a function and keep using it afterwards. Rust treats passing a String by value as handing it over. This program looks innocent to a Python reader.

rust
fn shout(s: String) -> String {
    s.to_uppercase()
}

fn main() {
    let name = String::from("ada");
    let loud = shout(name);
    println!("{loud} {name}");
}

The compiler refuses it.

Text
error[E0382]: borrow of moved value: `name`
 --> src/main.rs:8:23
  |
6 |     let name = String::from("ada");
  |         ---- move occurs because `name` has type `String`, which does not implement the `Copy` trait
7 |     let loud = shout(name);
  |                      ---- value moved here
8 |     println!("{loud} {name}");
  |                       ^^^^ value borrowed here after move
  |
note: consider changing this parameter type in function `shout` to borrow instead if owning the value isn't necessary
 --> src/main.rs:1:13
  |
1 | fn shout(s: String) -> String {
  |    -----    ^^^^^^ this parameter takes ownership of the value
  |    |
  |    in this function
help: consider cloning the value if the performance cost is acceptable
  |
7 |     let loud = shout(name.clone());
  |                          ++++++++

For more information about this error, try `rustc --explain E0382`.

The message is a diagnosis, not just a complaint. shout takes s: String, so the call on line 7 moves name into the function, and by line 8 the variable has nothing in it. The note points at the parameter and suggests borrowing, which is almost always the right fix. The help text offers .clone() as the alternative: it works, and it also copies the heap allocation, which is the cost the compiler is asking you to acknowledge. Here is the borrowing version.

rust
fn shout(s: &str) -> String {
    s.to_uppercase()
}

fn main() {
    let name = String::from("ada");
    let loud = shout(&name);
    println!("{loud} {name}");
}
Text
ADA ada

Two small edits did it. The parameter became &str, so the function only needs to read, and the call passes &name, so main keeps ownership. The rule to take from this: if a function only looks at its argument, accept a reference. If it needs to keep or consume the value, accept the owned type and let the caller decide whether to clone. Cloning to silence the compiler is legitimate while you are learning, but a codebase full of .clone() has traded Python's costs for Python's costs plus noise.

You cannot change a list while looping over it

This one catches everyone, because in Python it is a bug that does not stop the program. Appending to a list inside a loop over the same list either loops forever or skips elements. Rust refuses to compile it.

rust
fn main() {
    let mut scores = vec![40, 55, 70, 90];
    for s in &scores {
        if *s < 60 {
            scores.push(60);
        }
    }
    println!("{scores:?}");
}
Text
error[E0502]: cannot borrow `scores` as mutable because it is also borrowed as immutable
 --> src/main.rs:5:13
  |
3 |       for s in &scores {
  |       -        -------
  |       |        |
  |       |        immutable borrow occurs here
  |  _____|        immutable borrow later used here
  | |
4 | |         if *s < 60 {
5 | |             scores.push(60);
  | |             ^^^^^^^^^^^^^^^ mutable borrow occurs here
6 | |         }
7 | |     }
  | |_____- this for loop borrows `scores` immutably, preventing mutation within its body
  |
  = help: consider using an index-based loop instead, or collecting modifications into a separate collection

For more information about this error, try `rustc --explain E0502`.

The loop holds an immutable borrow of scores for its whole duration, and push needs a mutable one. The rule behind the error is simple to state: at any moment you may have many readers or one writer, never both. That sounds like a restriction until you notice the Python behaviour it prevents. The compiler's help line is the right instinct, and the better fix is usually to stop mutating in place and build a new value.

rust
fn main() {
    let scores = vec![40, 55, 70, 90];
    let floored: Vec<i32> = scores.iter().map(|&s| s.max(60)).collect();
    println!("{floored:?}");
}
Text
[60, 60, 70, 90]

The chain iter().map(...).collect() is Rust's list comprehension. iter() yields references, the closure pattern |&s| copies each integer out of its reference, s.max(60) raises anything below the floor, and collect() gathers the results into the Vec<i32> the annotation names. The annotation is required here: collect can build many container types, and the compiler needs you to say which. If you do want to change elements in place, for s in &mut scores hands you a mutable reference to each one, and that is allowed, because the vector itself is not being resized while it is walked.

Mutability is opt-in

Python variables can always be rebound and most objects can always be changed. In Rust a binding is immutable unless you write mut, and a method that changes its receiver must declare &mut self.

rust
struct Account { balance: i64 }
impl Account { fn deposit(&mut self, n: i64) { self.balance += n; } }
fn main() {
    let acct = Account { balance: 0 };
    acct.deposit(5);
    println!("{}", acct.balance);
}
Text
error[E0596]: cannot borrow `acct` as mutable, as it is not declared as mutable
 --> src/main.rs:5:5
  |
5 |     acct.deposit(5);
  |     ^^^^ cannot borrow as mutable
  |
help: consider changing this to be mutable
  |
4 |     let mut acct = Account { balance: 0 };
  |         +++

For more information about this error, try `rustc --explain E0596`.

The fix is the one the compiler prints: let mut acct. The point of the rule is that you can read a function and know which values it can change by looking at the keywords, without tracing every call. When a reviewer sees mut, they know to look for the writes. Variables and mutability goes through shadowing, which gives you Python's rebind-with-a-new-type habit without the mutation.

Errors are return values

Python raises and catches, and the signature of a function says nothing about what might come out of it. Rust puts failure in the type. A function that can fail returns Result<T, E>, and the ? operator is the short form of "if this failed, return the error to my caller", which is the same early exit an uncaught exception performs, except that it is visible on the line where it happens. The next program reads two settings from a map, with its own error type, and shows both the success and the failure paths.

rust
use std::collections::HashMap;
use std::fmt;
use std::num::ParseIntError;

#[derive(Debug)]
enum ConfigError {
    MissingKey(&'static str),
    BadNumber { key: &'static str, source: ParseIntError },
}

impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ConfigError::MissingKey(k) => write!(f, "missing key `{k}`"),
            ConfigError::BadNumber { key, source } => write!(f, "`{key}` is not a number: {source}"),
        }
    }
}

impl std::error::Error for ConfigError {}

fn read_u16(map: &HashMap<&str, &str>, key: &'static str) -> Result<u16, ConfigError> {
    let raw = map.get(key).ok_or(ConfigError::MissingKey(key))?;
    raw.trim()
        .parse::<u16>()
        .map_err(|source| ConfigError::BadNumber { key, source })
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let good = HashMap::from([("port", "8080"), ("workers", " 4 ")]);
    println!("port={} workers={}", read_u16(&good, "port")?, read_u16(&good, "workers")?);

    let bad = HashMap::from([("port", "80x80")]);
    for key in ["port", "workers"] {
        match read_u16(&bad, key) {
            Ok(v) => println!("{key}={v}"),
            Err(e) => println!("error: {e}"),
        }
    }
    read_u16(&bad, "port")?;
    Ok(())
}
Text
port=8080 workers=4
error: `port` is not a number: invalid digit found in string
error: missing key `workers`
Error: BadNumber { key: "port", source: ParseIntError { kind: InvalidDigit } }

Start with the enum. ConfigError has one variant per way the function can fail, and the second variant carries the underlying ParseIntError as a field, which is how Rust keeps a cause chain without exception chaining. Implementing Display turns each variant into a sentence for humans, and the empty impl std::error::Error makes the type usable as a Box<dyn Error>, which is what lets main return a Result and have the runtime print the failure.

In read_u16, map.get(key) returns an Option, and ok_or converts the missing case into an error so that ? can propagate it. The parse call returns a different error type, so map_err wraps it in our variant before the second ?. Each conversion is a line you can see, where Python would have let a KeyError or ValueError escape from wherever it happened to occur.

The output shows the three outcomes. The good path prints both numbers; note that " 4 " parsed because the function trims first. The bad path printed a readable sentence for each case through the loop, and the final ? in main let the error escape, which prints the Debug form with Error: in front and exits with status 1. That last line is ugly on purpose: a real program would print the Display form, and the libraries thiserror and anyhow remove most of the boilerplate above (error handling in Rust compares the approaches). A value that may be absent is an Option, and Option explained covers why the compiler making you handle None removes the Python AttributeError: 'NoneType' class of bug entirely.

Classes become structs, enums and traits

A Python class bundles data, methods and, through inheritance, a place in a hierarchy. Rust separates the three. Data lives in a struct or an enum, methods live in an impl block, and shared behaviour across unrelated types lives in a trait, which plays the role of a protocol or abstract base class. There is no inheritance of fields or implementations between types.

rust
use std::fmt;

#[derive(Debug)]
enum Shape {
    Circle { r: f64 },
    Rect { w: f64, h: f64 },
}

trait Area {
    fn area(&self) -> f64;
}

impl Area for Shape {
    fn area(&self) -> f64 {
        match self {
            Shape::Circle { r } => std::f64::consts::PI * r * r,
            Shape::Rect { w, h } => w * h,
        }
    }
}

struct Account {
    owner: String,
    balance_cents: i64,
}

impl Account {
    fn new(owner: &str) -> Self {
        Self { owner: owner.to_string(), balance_cents: 0 }
    }
    fn deposit(&mut self, cents: i64) {
        self.balance_cents += cents;
    }
}

impl fmt::Display for Account {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}: ${}.{:02}", self.owner, self.balance_cents / 100, self.balance_cents % 100)
    }
}

fn main() {
    let shapes = [Shape::Circle { r: 1.5 }, Shape::Rect { w: 2.0, h: 3.5 }];
    for s in &shapes {
        println!("{s:?} -> {:.3}", s.area());
    }
    let mut acct = Account::new("ada");
    acct.deposit(12_345);
    println!("{acct}");
}
Text
Circle { r: 1.5 } -> 7.069
Rect { w: 2.0, h: 3.5 } -> 7.000
ada: $123.45

The Account struct is the translation of a plain class. new is an ordinary associated function, not a special method, and Rust has no __init__. deposit takes &mut self because it changes the account, and Display is the __str__ equivalent: implementing the trait is what makes println!("{acct}") work. The balance is stored in integer cents on purpose; floats lose pennies, and the same advice applies in Python.

The Shape enum shows where Rust diverges. In Python you would write a base class and two subclasses, each overriding area. Here the two shapes are variants of one enum, each carrying its own fields, and the match inside the trait implementation is exhaustive: add a Triangle variant and the compiler flags every match that forgot it. That exhaustiveness is the part of a class hierarchy that Python cannot give you, and it is why enums do most of the work that inheritance does elsewhere. Reach for a trait when the set of types is open (callers add their own), and an enum when it is closed (you know every case). Duck typing translates to generics with a trait bound, fn print_area<T: Area>(x: &T), which the compiler checks at the call site instead of at runtime; traits explained goes through it.

Comprehensions and iterator chains

Python's comprehensions and generator expressions map onto iterator adaptors. The chain is read left to right, nothing runs until a consuming call such as collect or sum pulls values through, and the closures can borrow from the surrounding scope.

rust
fn main() {
    let words = ["rust", "python", "go", "java", "c", "zig"];
    let lengths: Vec<usize> = words
        .iter()
        .filter(|w| w.len() > 2)
        .map(|w| w.len())
        .collect();
    println!("{lengths:?}");

    let total: usize = lengths.iter().sum();
    let longest = words.iter().max_by_key(|w| w.len());
    println!("total={total} longest={longest:?}");
}
Text
[4, 6, 4, 3]
total=17 longest=Some("python")

The first chain is [len(w) for w in words if len(w) > 2]. The second statement sums the result, and max_by_key is Python's max(words, key=len), returning an Option because an empty slice has no maximum, hence Some("python") in the output. The abstraction costs nothing at runtime: the compiler fuses the adaptors into one loop. Iterators explained shows the same chains with the laziness made visible.

Tooling: one command replaces five

In Python you reach forIn Rust it is
python -m venv and pip installcargo add serde; dependencies are resolved per project into Cargo.lock
pytestcargo test, with unit tests in the same file as the code
black or ruff formatcargo fmt
ruff or pylintcargo clippy
mypycargo check, which is the compiler without producing a binary
pip wheel and a Docker layer with an interpretercargo build --release and one native executable

There is no environment to activate, because a Rust project carries its own dependency resolution in the manifest and lockfile. The cost is the first build of a large dependency tree, which is slower than pip install because Cargo compiles from source; later builds are incremental. Run cargo check in your edit loop, since it skips code generation and returns in a fraction of the time.

What the move buys you, measured

Speed is the reason many Python developers look at Rust, and the honest answer depends on what the Python was doing. I measured two workloads on the M1 Max, release build for Rust and Python 3.14.6 for the other side. The word counts are the range over three runs, and the Collatz figure for Python is a single run because it takes fifteen seconds.

The first is a tight numeric loop: the longest Collatz chain for every starting number below one million. Both versions return 837799 with a chain length of 525.

WorkloadPython 3.14.6Rust 1.99 (release)Ratio
Collatz, start below 1,000,00014.55 s0.139 sabout 105x
Count 5,000,000 words, hand-written dict loop0.70 to 0.94 s0.134 s5x to 7x
Count 5,000,000 words, collections.Counter0.41 to 0.42 s0.134 sabout 3x

The spread is the lesson. A loop that spends its time in the interpreter, doing arithmetic on small integers one at a time, is where the roughly hundredfold gap lives. The word count is closer because Counter is written in C: when the hot work already happens inside a native routine, Rust has little left to win, and the gap shrinks to the difference between two hash tables. Rust also did the counting without allocating a string per word, which matters for memory more than for time. Your own workload will land somewhere in this range, and the way to find out is to profile the Python first rather than assume.

Where the translation breaks, and when to stay in Python

Rust is the wrong tool in several situations, and several of them describe most of what Python developers do day to day.

  • Exploration and notebooks. If the work is a data question you answer in an afternoon, the compile step, the type declarations and the borrow checker are a tax with no return. Python with NumPy or pandas is faster to a result and the arrays already run in native code.
  • Glue and scripts. A hundred lines that move files and call APIs are quicker to write and change in Python, and nobody profiles them.
  • I/O-bound services with modest load. If a service spends its time waiting on a database, the interpreter is not the bottleneck, and rewriting it buys little. Measure before deciding.
  • Heavy reliance on dynamic features. Monkey patching, runtime attribute injection and deep metaprogramming have no direct Rust counterpart. Macros cover some of the ground at compile time, but the design will differ.

The middle path is often the best one. Profile the Python, find the one function that dominates, and move only that into a Rust extension module that Python imports like any other. PyO3 and Maturin walks through the workflow, and it lets a team keep its Python code, its tests and its deployment while the 5 percent of the code that costs 95 percent of the time runs at native speed. If you are still weighing the decision at a project level, Rust versus Python argues both sides.

A first week that works

Install the toolchain with rustup, then rewrite three scripts you have already written in Python, not new ideas: a word counter like the one above, a small file renamer, and something that reads JSON. You know the correct answers already, so every compiler error is about Rust and not about your problem. Read the full error message each time, since the help lines are accurate more often than not. When you have written those, take the Rust learning path for the order of topics that follows, and try the project ideas when you want something larger.

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.