Convert Python to Rust
Convert Python code to idiomatic Rust. This skill extends meta-convert-dev with Python-to-Rust specific type mappings, idiom translations, and tooling for transforming dynamic, garbage-collected Python code into static, ownership-based Rust.
This Skill Extends
meta-convert-dev- Foundational conversion patterns (APTV workflow, testing strategies)
For general concepts like the Analyze → Plan → Transform → Validate workflow, testing strategies, and common pitfalls, see the meta-skill first.
This Skill Adds
- Type mappings: Python types → Rust types (dynamic → static)
- Idiom translations: Python patterns → idiomatic Rust
- Error handling: Exceptions → Result<T, E>
- Async patterns: asyncio → tokio/async-std
- Memory/Ownership: GC + dynamic typing → ownership + borrowing + static types
- Type system: Duck typing → generics + traits
This Skill Does NOT Cover
- General conversion methodology - see
meta-convert-dev - Python language fundamentals - see
lang-python-dev - Rust language fundamentals - see
lang-rust-dev - Reverse conversion (Rust → Python) - see
convert-rust-python
Quick Reference
| Python | Rust | Notes |
|--------|------|-------|
| int | i32, i64, i128, num_bigint::BigInt | Python has arbitrary precision |
| float | f64 | Default float |
| bool | bool | Direct mapping |
| str | String, &str | Owned vs borrowed |
| bytes | Vec<u8>, &[u8] | Owned vs borrowed |
| list[T] | Vec<T> | Growable array |
| tuple | (T, U, ...) | Fixed-size tuple |
| dict[K, V] | HashMap<K, V>, BTreeMap<K, V> | Hash vs ordered |
| set[T] | HashSet<T>, BTreeSet<T> | Hash vs ordered |
| None | Option<T> | Explicit nullable |
| Union[T, U] | enum { A(T), B(U) } | Tagged union |
| Callable[[Args], Ret] | Fn(Args) -> Ret | Function trait |
| async def | async fn | Async function |
| @dataclass | #[derive(Debug, Clone)] struct | Data classes |
| Exception | Result<T, E> | Error handling |
When Converting Code
- Analyze source thoroughly before writing target
- Map types first - create type equivalence table
- Handle arbitrary-precision integers - decide if
i64is enough or if you needBigInt - Preserve semantics over syntax similarity
- Adopt Rust idioms - don't write "Python code in Rust syntax"
- Handle edge cases - None, exceptions, dynamic typing assumptions
- Test equivalence - same inputs → same outputs
Type System Mapping
Primitive Types
| Python | Rust | Notes |
|--------|------|-------|
| int | i32 | Default for small integers |
| int | i64 | Large integers (64-bit) |
| int | i128 | Very large integers (128-bit) |
| int | num_bigint::BigInt | Python default - arbitrary precision |
| float | f64 | IEEE 754 double precision |
| bool | bool | Direct mapping |
| str | String | Owned, heap-allocated UTF-8 |
| str | &str | Borrowed string slice |
| bytes | Vec<u8> | Owned byte vector |
| bytes | &[u8] | Borrowed byte slice |
| bytearray | Vec<u8> | Mutable byte vector |
| None | Option<T> | Use None variant |
| ... (Ellipsis) | - | No direct equivalent |
Critical Note on Integers: Python's int type has arbitrary precision and never overflows. Rust integers are fixed-size and can overflow (panic in debug, wrap in release). Always validate range or use BigInt for Python-like behavior.
Collection Types
| Python | Rust | Notes |
|--------|------|-------|
| list[T] | Vec<T> | Owned, growable, ordered |
| tuple | (T, U, ...) | Fixed-size, immutable |
| tuple[T, ...] | Vec<T> | Variable-length tuple → Vec |
| dict[K, V] | HashMap<K, V> | Hash-based, unordered |
| dict[K, V] | BTreeMap<K, V> | Tree-based, ordered |
| set[T] | HashSet<T> | Hash-based, unique values |
| set[T] | BTreeSet<T> | Tree-based, ordered unique |
| frozenset[T] | HashSet<T> | Immutable by default in Rust |
| collections.deque | VecDeque<T> | Double-ended queue |
| collections.OrderedDict | indexmap::IndexMap<K, V> | Insertion-order map |
| collections.defaultdict | HashMap + entry() API | Use or_insert() pattern |
| collections.Counter | HashMap<T, usize> | Count occurrences |
Composite Types
| Python | Rust | Notes |
|--------|------|-------|
| class (data) | struct | Data containers |
| class (behavior) | trait + impl | Behavior contracts |
| @dataclass | #[derive(Debug, Clone)] struct | Auto-derive common traits |
| typing.Protocol | trait | Structural types → nominal traits |
| typing.TypedDict | struct | Named fields |
| typing.NamedTuple | struct or tuple | Prefer struct for clarity |
| enum.Enum | enum | Algebraic data types |
| typing.Literal["a", "b"] | enum { A, B } | Literal types → enums |
| typing.Union[T, U] | enum { A(T), B(U) } | Tagged union |
| typing.Optional[T] | Option<T> | Nullable types |
| typing.Callable[[Args], Ret] | Fn(Args) -> Ret | Function types |
| typing.Generic[T] | <T> | Generic types |
Type Annotations → Generics + Traits
| Python | Rust | Notes |
|--------|------|-------|
| def f(x: T) -> T | fn f<T>(x: T) -> T | Unconstrained generic |
| def f(x: Iterable[T]) | fn f<T, I: IntoIterator<Item=T>> | Trait bound |
| def f(x: Sequence[T]) | fn f<T>(x: &[T]) | Slice for sequences |
| x: Any | Avoid - use generics | Any is a code smell |
| x: object | Avoid - use generics | No Object root in Rust |
Idiom Translation
Pattern 1: None Handling (Optional Chaining)
Python:
# Optional chaining with walrus operator
if user := get_user(user_id):
name = user.name
else:
name = "Anonymous"
# Or simpler
name = user.name if user else "Anonymous"
Rust:
// Option combinators
let name = get_user(user_id)
.map(|u| u.name.clone())
.unwrap_or_else(|| "Anonymous".to_string());
// Or with as_ref() to avoid moving
let name = get_user(user_id)
.as_ref()
.map(|u| u.name.as_str())
.unwrap_or("Anonymous");
Why this translation:
- Python uses truthiness (
if user) while Rust uses explicitOption<T> - Rust's combinator methods (
map,unwrap_or) are more explicit about handling theNonecase as_ref()convertsOption<T>toOption<&T>to avoid consuming the value
Pattern 2: List Comprehensions → Iterator Chains
Python:
# List comprehension
squared_evens = [x * x for x in numbers if x % 2 == 0]
# Generator expression
total = sum(x * x for x in numbers if x % 2 == 0)
Rust:
// Iterator chain (collect for Vec)
let squared_evens: Vec<i32> = numbers
.iter()
.filter(|x| *x % 2 == 0)
.map(|x| x * x)
.collect();
// Iterator chain (sum for aggregation)
let total: i32 = numbers
.iter()
.filter(|x| *x % 2 == 0)
.map(|x| x * x)
.sum();
Why this translation:
- Python list comprehensions are eager; Rust iterators are lazy (more efficient)
- Rust requires explicit
collect()to materialize into a collection - Terminal operations like
sum()consume the iterator automatically
Pattern 3: Dictionary Operations
Python:
# Get with default
value = config.get("timeout", 30)
# Setdefault pattern
cache.setdefault(key, expensive_compute())
# Dictionary comprehension
squared = {k: v * v for k, v in items.items()}
Rust:
// Get with default
let value = config.get("timeout").copied().unwrap_or(30);
// Entry API (doesn't compute if present)
let value = cache.entry(key).or_insert_with(|| expensive_compute());
// Collect from iterator
let squared: HashMap<K, i32> = items
.into_iter()
.map(|(k, v)| (k, v * v))
.collect();
Why this translation:
- Rust's
entry()API is more efficient than Python'ssetdefault()for expensive defaults or_insert_with()takes a closure, only calling it if the key is missing- Rust's iterator
collect()can build many collection types, includingHashMap
Pattern 4: String Formatting
Python:
# f-strings (Python 3.6+)
message = f"User {user.name} has {count} items"
# format method
message = "User {} has {} items".format(user.name, count)
# % formatting (old style)
message = "User %s has %d items" % (user.name, count)
Rust:
// format! macro (heap-allocated)
let message = format!("User {} has {} items", user.name, count);
// print! / println! macros (direct output)
println!("User {} has {} items", user.name, count);
// write! macro (into a buffer)
use std::fmt::Write;
let mut buf = String::new();
write!(&mut buf, "User {} has {} items", user.name, count).unwrap();
Why this translation:
- Rust's
format!macro is compile-time checked for type safety {}is the default placeholder; use{:?}for debug output,{:#?}for pretty-print- Rust doesn't have string interpolation; use macros instead
Pattern 5: Duck Typing → Traits
Python:
# Duck typing - if it has a .read() method, it's file-like
def process_data(file_like):
data = file_like.read()
return parse(data)
# Works with files, StringIO, BytesIO, etc.
Rust:
// Trait bounds - explicit interface
use std::io::Read;
fn process_data<R: Read>(mut reader: R) -> Result<Data, Error> {
let mut data = String::new();
reader.read_to_string(&mut data)?;
parse(&data)
}
// Works with File, Cursor, TcpStream, etc. (anything implementing Read)
Why this translation:
- Python relies on runtime checks (duck typing); Rust checks at compile time
- Rust traits are explicit contracts, catching errors early
- Generic functions in Rust are monomorphized (one compiled version per concrete type)
Pattern 6: Context Managers → RAII
Python:
# with statement for resource management
with open("data.txt") as f:
data = f.read()
# File automatically closed
# Custom context manager
with lock_held(mutex):
# Critical section
pass
# Lock automatically released
Rust:
// RAII - Drop trait handles cleanup
{
let f = File::open("data.txt")?;
let mut data = String::new();
f.read_to_string(&mut data)?;
// File automatically closed when f goes out of scope
}
// Mutex guard - RAII
{
let guard = mutex.lock().unwrap();
// Critical section - guard holds the lock
// Lock automatically released when guard is dropped
}
Why this translation:
- Python uses
__enter__/__exit__protocols; Rust usesDroptrait - Rust's ownership system guarantees cleanup at scope exit (compile-time enforced)
- No need for explicit
withstatement - scope-based cleanup is automatic
Pattern 7: Dynamic Attribute Access
Python:
# Dynamic attribute access
value = getattr(obj, "field", default)
setattr(obj, "field", value)
hasattr(obj, "field")
# Dynamic method calls
method = getattr(obj, method_name)
result = method(*args)
Rust:
// Static access only - use enums for dynamic behavior
enum Field {
Name(String),
Age(u32),
Email(String),
}
impl Object {
fn get_field(&self, field: &str) -> Option<Field> {
match field {
"name" => Some(Field::Name(self.name.clone())),
"age" => Some(Field::Age(self.age)),
"email" => Some(Field::Email(self.email.clone())),
_ => None,
}
}
}
// For true dynamic behavior, use HashMap
struct DynamicObject {
fields: HashMap<String, Value>,
}
Why this translation:
- Rust has no runtime reflection for dynamic attribute access
- Use enums for known variants,
HashMapfor truly dynamic data - Trade runtime flexibility for compile-time safety and performance
Pattern 8: Exception Chaining
Python:
# Exception chaining
try:
data = fetch_data(url)
except NetworkError as e:
raise ProcessingError(f"Failed to fetch {url}") from e
# Catching and re-raising
try:
risky_operation()
except Exception:
logger.error("Operation failed")
raise
Rust:
// Error conversion with context
fn fetch_data(url: &str) -> Result<Data, ProcessingError> {
let data = fetch(url)
.map_err(|e| ProcessingError::FetchFailed {
url: url.to_string(),
source: e,
})?;
Ok(data)
}
// Using anyhow for error context
use anyhow::Context;
fn fetch_data(url: &str) -> anyhow::Result<Data> {
fetch(url)
.context(format!("Failed to fetch {}", url))?;
Ok(data)
}
Why this translation:
- Rust doesn't have exception chaining; use nested error types or libraries like
anyhow map_err()transforms errors explicitly?operator propagates errors up the call stack (like re-raising)
Pattern 9: Multiple Return Values
Python:
# Tuple unpacking
def parse_coord(s):
parts = s.split(",")
return int(parts[0]), int(parts[1])
x, y = parse_coord("10,20")
Rust:
// Tuple return
fn parse_coord(s: &str) -> Result<(i32, i32), ParseError> {
let parts: Vec<&str> = s.split(',').collect();
let x = parts[0].parse()?;
let y = parts[1].parse()?;
Ok((x, y))
}
let (x, y) = parse_coord("10,20")?;
// Named struct (preferred for clarity)
#[derive(Debug)]
struct Coord { x: i32, y: i32 }
fn parse_coord(s: &str) -> Result<Coord, ParseError> {
let parts: Vec<&str> = s.split(',').collect();
Ok(Coord {
x: parts[0].parse()?,
y: parts[1].parse()?,
})
}
Why this translation:
- Both languages support tuple returns
- Rust prefers named structs for complex returns (better documentation, field names)
- Rust requires explicit error handling (hence
Result)
Pattern 10: Decorators → Macros or Trait Implementations
Python:
# Function decorator
@cache
def expensive_func(x):
return compute(x)
# Class decorator
@dataclass
class Point:
x: int
y: int
# Property decorator
@property
def full_name(self):
return f"{self.first} {self.last}"
Rust:
// Procedural macro (like class decorator)
#[derive(Debug, Clone, PartialEq)]
struct Point {
x: i32,
y: i32,
}
// Manual memoization (no decorator syntax)
use std::collections::HashMap;
use std::cell::RefCell;
thread_local! {
static CACHE: RefCell<HashMap<i32, i32>> = RefCell::new(HashMap::new());
}
fn expensive_func(x: i32) -> i32 {
CACHE.with(|cache| {
cache.borrow_mut().entry(x).or_insert_with(|| compute(x)).clone()
})
}
// Computed properties (no @property syntax)
impl Person {
fn full_name(&self) -> String {
format!("{} {}", self.first, self.last)
}
}
Why this translation:
- Rust has no decorator syntax; use
#[derive(...)]for common patterns - Function decorators require manual implementation or crates like
cached - Properties are just methods in Rust (no special syntax)
Error Handling
Python Exception Model → Rust Result Model
| Python | Rust | Notes |
|--------|------|-------|
| raise Exception("error") | return Err(Error::Message) | Exceptions → Result |
| try: ... except E: ... | match result { Ok(v) => ..., Err(e) => ... } | Pattern matching |
| try: ... except: ... | Anti-pattern - always specify error type | No catch-all |
| try: ... finally: ... | RAII / Drop trait | Automatic cleanup |
| raise ... from ... | Nested error types or anyhow::Context | Error chains |
| assert x, "msg" | assert!(x, "msg") | Panic for invariants |
Exception Hierarchy Translation
Python:
# Exception hierarchy
class AppError(Exception):
pass
class NetworkError(AppError):
def __init__(self, url, status):
self.url = url
self.status = status
super().__init__(f"Network error for {url}: {status}")
class ParseError(AppError):
def __init__(self, message):
self.message = message
super().__init__(message)
# Raising exceptions
if response.status_code != 200:
raise NetworkError(url, response.status_code)
# Catching exceptions
try:
data = fetch_and_parse(url)
except NetworkError as e:
log.error(f"Network error: {e.url} returned {e.status}")
retry()
except ParseError as e:
log.error(f"Parse error: {e.message}")
return None
Rust:
// Error enum with thiserror
use thiserror::Error;
#[derive(Debug, Error)]
enum AppError {
#[error("Network error for {url}: {status}")]
Network { url: String, status: u16 },
#[error("Parse error: {message}")]
Parse { message: String },
#[error(transparent)]
Io(#[from] std::io::Error),
}
// Returning errors
fn fetch(url: &str) -> Result<Data, AppError> {
let response = http_get(url)?;
if response.status() != 200 {
return Err(AppError::Network {
url: url.to_string(),
status: response.status(),
});
}
Ok(response.data())
}
// Handling errors
match fetch_and_parse(url) {
Ok(data) => process(data),
Err(AppError::Network { url, status }) => {
log::error!("Network error: {} returned {}", url, status);
retry()?;
}
Err(AppError::Parse { message }) => {
log::error!("Parse error: {}", message);
return None;
}
Err(e) => return Err(e),
}
Why this translation:
- Python uses exception inheritance; Rust uses enum variants
- Rust's
thiserrorcrate providesDisplayandErrortrait implementations ?operator propagates errors (like Python's exception unwinding)- Pattern matching is more explicit than try-except blocks
Error Propagation Patterns
Python:
# Implicit propagation (exception bubbles up)
def outer():
return inner() # Exceptions propagate automatically
def inner():
raise ValueError("error")
Rust:
// Explicit propagation with ?
fn outer() -> Result<Data, Error> {
let data = inner()?; // ? propagates Err variants
Ok(data)
}
fn inner() -> Result<Data, Error> {
Err(Error::Message("error".to_string()))
}
Why this translation:
- Python exceptions propagate implicitly; Rust requires explicit
?or pattern matching - Rust's approach forces you to think about error handling at each call site
- Type system ensures errors are handled or explicitly propagated
Async Patterns
Python asyncio → Rust tokio/async-std
| Python | Rust (tokio) | Notes |
|--------|--------------|-------|
| async def f(): ... | async fn f() { ... } | Async function |
| await coro | coro.await | Await syntax |
| asyncio.run(coro) | tokio::runtime::Runtime::new()?.block_on(coro) | Run async code |
| asyncio.gather(*coros) | tokio::join!(coros) or futures::join_all | Concurrent execution |
| asyncio.create_task(coro) | tokio::spawn(coro) | Background task |
| asyncio.sleep(secs) | tokio::time::sleep(Duration::from_secs(secs)) | Async sleep |
| asyncio.wait_for(coro, timeout) | tokio::time::timeout(duration, coro) | Timeout |
| asyncio.Queue | tokio::sync::mpsc::channel | Async channel |
| asyncio.Lock | tokio::sync::Mutex | Async mutex |
Basic Async Function Translation
Python:
import asyncio
async def fetch_user(user_id: int) -> User:
async with aiohttp.ClientSession() as session:
async with session.get(f"/users/{user_id}") as response:
data = await response.json()
return User(**data)
# Running async code
async def main():
user = await fetch_user(123)
print(user)
asyncio.run(main())
Rust:
use tokio;
use reqwest;
async fn fetch_user(user_id: u32) -> Result<User, reqwest::Error> {
let url = format!("/users/{}", user_id);
let user = reqwest::get(&url)
.await?
.json::<User>()
.await?;
Ok(user)
}
// Running async code
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let user = fetch_user(123).await?;
println!("{:?}", user);
Ok(())
}
Why this translation:
- Both use
async/awaitsyntax - Python's context managers become RAII in Rust (automatic cleanup)
- Rust requires explicit error handling (
Result+?) #[tokio::main]macro sets up the async runtime automatically
Concurrent Execution
Python:
# asyncio.gather for concurrent execution
users, orders = await asyncio.gather(
fetch_users(),
fetch_orders()
)
# asyncio.create_task for background tasks
task1 = asyncio.create_task(fetch_users())
task2 = asyncio.create_task(fetch_orders())
users = await task1
orders = await task2
Rust:
// tokio::join! for concurrent execution (fixed number)
let (users, orders) = tokio::join!(
fetch_users(),
fetch_orders()
);
// tokio::spawn for background tasks
let task1 = tokio::spawn(fetch_users());
let task2 = tokio::spawn(fetch_orders());
let users = task1.await??; // First ? for JoinError, second for task error
let orders = task2.await??;
// futures::join_all for dynamic list
use futures::future::join_all;
let tasks: Vec<_> = ids.into_iter().map(fetch_user).collect();
let users = join_all(tasks).await;
Why this translation:
tokio::join!is macro-based (compile-time), similar toasyncio.gathertokio::spawncreates a separate task (likecreate_task)- Spawned tasks return
JoinHandle, requiring double??to unwrap both join and task errors
Async Streams/Generators
Python:
# Async generator
async def fetch_pages(url: str):
page = 1
while True:
response = await fetch(f"{url}?page={page}")
if not response.ok:
break
yield await response.json()
page += 1
# Consuming async generator
async for page in fetch_pages(url):
process(page)
Rust:
// Async stream (using async-stream crate)
use async_stream::stream;
use futures::stream::Stream;
fn fetch_pages(url: String) -> impl Stream<Item = Result<Page, Error>> {
stream! {
let mut page = 1;
loop {
let response = fetch(&format!("{}?page={}", url, page)).await?;
if !response.status().is_success() {
break;
}
yield response.json::<Page>().await?;
page += 1;
}
}
}
// Consuming async stream
use futures::stream::StreamExt;
let mut pages = fetch_pages(url);
while let Some(result) = pages.next().await {
match result {
Ok(page) => process(page),
Err(e) => eprintln!("Error: {}", e),
}
}
Why this translation:
- Python's
async for→ Rust'sStreamExt::next()in a loop - Rust requires
async-streamcrate for generator-like syntax - Streams yield
Resultfor error handling (Python would raise exceptions)
Cancellation and Timeouts
Python:
# Timeout with asyncio.wait_for
try:
result = await asyncio.wait_for(fetch_data(url), timeout=5.0)
except asyncio.TimeoutError:
print("Request timed out")
# Manual cancellation
task = asyncio.create_task(long_operation())
# ... later
task.cancel()
try:
await task
except asyncio.CancelledError:
print("Task was cancelled")
Rust:
// Timeout with tokio::time::timeout
use tokio::time::{timeout, Duration};
match timeout(Duration::from_secs(5), fetch_data(url)).await {
Ok(Ok(result)) => println!("Success: {:?}", result),
Ok(Err(e)) => println!("Request failed: {}", e),
Err(_) => println!("Request timed out"),
}
// Manual cancellation via drop
let handle = tokio::spawn(long_operation());
// ... later
handle.abort(); // Cancel the task
match handle.await {
Ok(result) => println!("Completed: {:?}", result),
Err(e) if e.is_cancelled() => println!("Task was cancelled"),
Err(e) => println!("Task failed: {}", e),
}
Why this translation:
- Python uses
asyncio.wait_for; Rust usestokio::time::timeout - Rust's
timeoutreturnsResult<Result<T, E>, Elapsed>(nested Results) - Cancellation in Rust happens via
abort()onJoinHandle
Memory & Ownership
Python GC → Rust Ownership
| Python Model | Rust Model | Translation |
|--------------|------------|-------------|
| Reference counting + cycle detection | Ownership + borrowing | Explicit ownership transfer |
| Shared references everywhere | &T (immutable) or &mut T (mutable) | Borrow checker enforces aliasing rules |
| Mutable by default | Immutable by default (let vs let mut) | Explicit mutability |
| No lifetime tracking | Explicit lifetimes ('a) | Compiler ensures references are valid |
| del or rely on GC | Drop trait (RAII) | Automatic, deterministic cleanup |
Ownership Decision Patterns
Python (shared references):
# Python allows multiple mutable references
class Cache:
def __init__(self):
self.data = {}
def get(self, key):
return self.data.get(key)
def set(self, key, value):
self.data[key] = value
# Multiple references to cache
cache = Cache()
ref1 = cache
ref2 = cache
ref1.set("key", "value")
print(ref2.get("key")) # Works fine
Rust (explicit ownership):
use std::collections::HashMap;
struct Cache {
data: HashMap<String, String>,
}
impl Cache {
fn new() -> Self {
Self { data: HashMap::new() }
}
// Borrow immutably (read-only)
fn get(&self, key: &str) -> Option<&String> {
self.data.get(key)
}
// Borrow mutably (write access)
fn set(&mut self, key: String, value: String) {
self.data.insert(key, value);
}
}
// Single owner, multiple borrows
let mut cache = Cache::new();
cache.set("key".to_string(), "value".to_string());
println!("{:?}", cache.get("key"));
// For shared ownership, use Rc/Arc
use std::rc::Rc;
use std::cell::RefCell;
let cache = Rc::new(RefCell::new(Cache::new()));
let ref1 = Rc::clone(&cache);
let ref2 = Rc::clone(&cache);
ref1.borrow_mut().set("key".to_string(), "value".to_string());
println!("{:?}", ref2.borrow().get("key"));
Why this translation:
- Python's GC allows unrestricted shared mutable state
- Rust enforces "either one mutable reference OR many immutable references"
- For Python-like shared mutability, use
Rc<RefCell<T>>(single-threaded) orArc<Mutex<T>>(multi-threaded)
Avoiding Clone Overhead
Python (cloning is implicit and cheap):
def process_items(items):
# Items can be passed around freely
for item in items:
handle(item)
transform(item)
Rust (explicit borrowing to avoid clones):
// BAD: Unnecessary cloning
fn process_items(items: Vec<Item>) {
for item in items.clone() { // Clones entire vector!
handle(&item);
transform(&item);
}
}
// GOOD: Borrow instead
fn process_items(items: &[Item]) {
for item in items {
handle(item); // item is &Item
transform(item);
}
}
// If mutation needed, use &mut
fn process_items_mut(items: &mut [Item]) {
for item in items {
transform_in_place(item); // item is &mut Item
}
}
Why this translation:
- Python's reference counting makes passing references cheap
- Rust's ownership requires explicit choices: move, borrow, or clone
- Prefer borrowing (
&T,&mut T) over cloning for performance
Lifetime Elision and Annotations
Python (no lifetime concept):
class Parser:
def __init__(self, source):
self.source = source
def parse(self):
# Can reference self.source freely
return self.source.split()
Rust (explicit lifetimes):
// Lifetime elision - compiler infers lifetimes
struct Parser<'a> {
source: &'a str,
}
impl<'a> Parser<'a> {
fn new(source: &'a str) -> Self {
Self { source }
}
fn parse(&self) -> Vec<&'a str> {
self.source.split_whitespace().collect()
}
}
// The 'a lifetime ties the parser to the source string
// Parser cannot outlive the source
Why this translation:
- Python's GC allows references to outlive their source
- Rust's borrow checker prevents dangling references at compile time
- Explicit lifetimes document reference validity constraints
Type System Translation
Duck Typing → Generics + Traits
Python (duck typing):
# Any object with .read() method works
def process_file(file_like):
data = file_like.read()
return parse(data)
# Works with files, StringIO, BytesIO, etc.
with open("data.txt") as f:
process_file(f)
Rust (trait bounds):
use std::io::Read;
fn process_file<R: Read>(mut reader: R) -> Result<Data, Error> {
let mut data = String::new();
reader.read_to_string(&mut data)?;
parse(&data)
}
// Works with File, Cursor, TcpStream, etc.
let f = File::open("data.txt")?;
process_file(f)?;
Why this translation:
- Python checks method existence at runtime (duck typing)
- Rust checks trait implementation at compile time
- Generics with trait bounds provide type safety without runtime overhead
TypedDict / NamedTuple → Struct
Python:
from typing import TypedDict, NamedTuple
# TypedDict (Python 3.8+)
class User(TypedDict):
id: int
name: str
email: str
# NamedTuple
class Point(NamedTuple):
x: int
y: int
user: User = {"id": 1, "name": "Alice", "email": "alice@example.com"}
point = Point(x=10, y=20)
Rust:
// Struct with derive macros
#[derive(Debug, Clone, PartialEq)]
struct User {
id: u32,
name: String,
email: String,
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct Point {
x: i32,
y: i32,
}
let user = User {
id: 1,
name: "Alice".to_string(),
email: "alice@example.com".to_string(),
};
let point = Point { x: 10, y: 20 };
Why this translation:
- Python's
TypedDictis for type hints; Rust's structs are enforced at compile time - Rust's
#[derive]macros auto-generate common trait implementations - Rust structs require owned data (
Stringnot&strfor struct fields)
Union Types → Enums
Python:
from typing import Union
# Union type
def process(value: Union[int, str]) -> str:
if isinstance(value, int):
return f"Number: {value}"
else:
return f"String: {value}"
result = process(42)
result = process("hello")
Rust:
// Tagged union (enum)
enum Value {
Number(i32),
Text(String),
}
fn process(value: Value) -> String {
match value {
Value::Number(n) => format!("Number: {}", n),
Value::Text(s) => format!("String: {}", s),
}
}
let result = process(Value::Number(42));
let result = process(Value::Text("hello".to_string()));
Why this translation:
- Python's
Unionis a type hint checked by mypy/pyright - Rust's enums are tagged unions, enforcing exhaustive pattern matching
- Rust catches missing match cases at compile time
Protocol (Structural) → Trait (Nominal)
Python:
from typing import Protocol
# Structural typing
class Drawable(Protocol):
def draw(self) -> None:
...
# Any class with a draw() method satisfies Drawable
class Circle:
def draw(self) -> None:
print("Drawing circle")
def render(obj: Drawable) -> None:
obj.draw()
render(Circle()) # Works due to structural typing
Rust:
// Nominal typing - must explicitly implement trait
trait Drawable {
fn draw(&self);
}
struct Circle;
impl Drawable for Circle {
fn draw(&self) {
println!("Drawing circle");
}
}
fn render<T: Drawable>(obj: &T) {
obj.draw();
}
render(&Circle); // Only works if Circle explicitly implements Drawable
Why this translation:
- Python's
Protocoluses structural typing (method signature match) - Rust's traits require explicit
impl Trait for Typedeclarations - Rust's approach enables better error messages and clearer intent
Common Pitfalls
1. Arbitrary Precision Integer Overflow
Problem:
// Python: unlimited integer size
# x = 10 ** 100 # Works fine
// Rust: fixed-size integers
let x: i32 = 10_i32.pow(100); // PANIC! Overflow in debug mode
Solution:
// Use appropriate size or BigInt
use num_bigint::BigInt;
use num_traits::pow::Pow;
let x = BigInt::from(10).pow(100_u32); // No overflow
Why this matters: Python integers never overflow; Rust integers panic (debug) or wrap (release).
2. Mutable Aliasing
Problem:
// Python: multiple mutable references allowed
# items = [1, 2, 3]
# ref1 = items
# ref2 = items
# ref1.append(4)
# ref2.append(5)
// Rust: borrow checker prevents this
let mut items = vec![1, 2, 3];
let ref1 = &mut items;
let ref2 = &mut items; // ERROR: cannot borrow as mutable more than once
Solution:
// Use scopes to separate borrows
{
let ref1 = &mut items;
ref1.push(4);
}
{
let ref2 = &mut items;
ref2.push(5);
}
// Or use interior mutability (Rc<RefCell<T>> or Arc<Mutex<T>>)
Why this matters: Rust prevents data races at compile time; Python allows them.
3. String Ownership
Problem:
// Python: strings are immutable but freely aliased
# name = user.get("name")
# print(name)
// Rust: String vs &str confusion
fn get_name(user: &HashMap<String, String>) -> &str {
user.get("name").unwrap() // Returns &String, not &str
}
Solution:
// Use .as_str() or accept &str
fn get_name(user: &HashMap<String, String>) -> &str {
user.get("name").unwrap().as_str()
}
// Or return Option<&str>
fn get_name(user: &HashMap<String, String>) -> Option<&str> {
user.get("name").map(|s| s.as_str())
}
Why this matters: Rust distinguishes owned (String) and borrowed (&str) strings.
4. Truthiness vs Explicit Boolean
Problem:
// Python: truthy/falsy values
# if items: # Empty list is falsy
# process(items)
// Rust: explicit boolean checks required
if items { // ERROR: expected `bool`, found `Vec<T>`
process(&items);
}
Solution:
// Explicitly check for emptiness
if !items.is_empty() {
process(&items);
}
// Or check for None
if let Some(value) = option {
process(value);
}
Why this matters: Rust has no implicit truthiness; always use explicit boolean expressions.
5. Default Arguments vs Builder Pattern
Problem:
// Python: default arguments
# def connect(host, port=80, timeout=30):
# ...
// Rust: no default arguments
fn connect(host: &str, port: u16, timeout: u64) -> Connection {
// All arguments required!
}
Solution:
// Use Option for optional parameters
fn connect(host: &str, port: Option<u16>, timeout: Option<u64>) -> Connection {
let port = port.unwrap_or(80);
let timeout = timeout.unwrap_or(30);
// ...
}
// Or use builder pattern
struct ConnectionBuilder {
host: String,
port: u16,
timeout: u64,
}
impl ConnectionBuilder {
fn new(host: String) -> Self {
Self { host, port: 80, timeout: 30 }
}
fn port(mut self, port: u16) -> Self {
self.port = port;
self
}
fn timeout(mut self, timeout: u64) -> Self {
self.timeout = timeout;
self
}
fn connect(self) -> Connection {
// ...
}
}
// Usage
let conn = ConnectionBuilder::new("localhost")
.port(8080)
.timeout(60)
.connect();
Why this matters: Rust has no default arguments; use Option or builder pattern for ergonomics.
6. List Modification During Iteration
Problem:
// Python: modifying list during iteration (undefined behavior)
# for item in items:
# if condition(item):
# items.remove(item) # Dangerous!
// Rust: borrow checker prevents this
for item in &items {
if condition(item) {
items.remove(item); // ERROR: cannot borrow as mutable while borrowed
}
}
Solution:
// Collect indices to remove, then remove in reverse
let to_remove: Vec<usize> = items.iter()
.enumerate()
.filter(|(_, item)| condition(item))
.map(|(i, _)| i)
.collect();
for &i in to_remove.iter().rev() {
items.remove(i);
}
// Or use retain
items.retain(|item| !condition(item));
Why this matters: Rust prevents iterator invalidation at compile time.
7. Global Mutable State
Problem:
// Python: global mutable state is easy
# counter = 0
# def increment():
# global counter
# counter += 1
// Rust: global mutable state requires unsafe or synchronization
static mut COUNTER: i32 = 0; // Unsafe!
fn increment() {
unsafe {
COUNTER += 1; // Requires unsafe block
}
}
Solution:
// Use static with Mutex or Atomic
use std::sync::Mutex;
static COUNTER: Mutex<i32> = Mutex::new(0);
fn increment() {
let mut counter = COUNTER.lock().unwrap();
*counter += 1;
}
// Or use atomic types for simple counters
use std::sync::atomic::{AtomicI32, Ordering};
static COUNTER: AtomicI32 = AtomicI32::new(0);
fn increment() {
COUNTER.fetch_add(1, Ordering::SeqCst);
}
Why this matters: Rust makes global mutable state explicit and safe via synchronization primitives.
8. Exception vs Result Propagation
Problem:
// Python: exceptions propagate automatically
# def outer():
# return inner() # Exceptions bubble up
# def inner():
# raise ValueError("error")
// Rust: forgetting ? operator
fn outer() -> Result<Data, Error> {
let data = inner(); // ERROR: expected `Data`, found `Result<Data, Error>`
Ok(data)
}
Solution:
// Use ? operator to propagate errors
fn outer() -> Result<Data, Error> {
let data = inner()?; // ? unwraps Ok or returns Err
Ok(data)
}
// Or match explicitly
fn outer() -> Result<Data, Error> {
match inner() {
Ok(data) => Ok(data),
Err(e) => Err(e),
}
}
Why this matters: Rust errors must be explicitly handled or propagated with ?.
Tooling
Code Translation Tools
| Tool | Purpose | Notes |
|------|---------|-------|
| py2rs | Python → Rust transpiler | Experimental, limited support |
| PyO3 | Python ↔ Rust FFI | Call Rust from Python or vice versa |
| maturin | Build Python extensions in Rust | For keeping Python interface, Rust backend |
| Manual translation | Full control | Recommended for production code |
Type Checking and Linting
| Python | Rust | Purpose |
|--------|------|---------|
| mypy | rustc | Static type checking |
| pylint | clippy | Linting and best practices |
| black | rustfmt | Code formatting |
| isort | - | Import sorting (built into rustfmt) |
Testing Frameworks
| Python | Rust | Purpose |
|--------|------|---------|
| pytest | Built-in #[test] + cargo test | Unit testing |
| hypothesis | proptest | Property-based testing |
| unittest.mock | mockall | Mocking |
| pytest-benchmark | criterion | Benchmarking |
Async Runtime
| Python | Rust | Purpose |
|--------|------|---------|
| asyncio | tokio | Async runtime (most popular) |
| trio | async-std | Alternative async runtime |
| uvloop | - | Faster event loop (not needed in Rust) |
Common Crate Equivalents
| Python Package | Rust Crate | Purpose |
|----------------|------------|---------|
| requests | reqwest | HTTP client |
| aiohttp | reqwest (async) | Async HTTP client |
| flask / fastapi | axum, actix-web | Web framework |
| pydantic | serde | Serialization/validation |
| click / argparse | clap | CLI argument parsing |
| logging | tracing, log | Logging/tracing |
| datetime | chrono | Date/time handling |
| pathlib | std::path | Path manipulation |
| json | serde_json | JSON parsing |
| re | regex | Regular expressions |
| sqlite3 | rusqlite | SQLite database |
| sqlalchemy | diesel, sqlx | ORM / SQL toolkit |
| pytest | cargo test | Testing framework |
Examples
Example 1: Simple - HTTP GET Request
Before (Python):
import requests
def fetch_user(user_id: int) -> dict:
"""Fetch user data from API."""
response = requests.get(f"https://api.example.com/users/{user_id}")
response.raise_for_status()
return response.json()
# Usage
try:
user = fetch_user(123)
print(f"User: {user['name']}")
except requests.HTTPError as e:
print(f"HTTP error: {e}")
except Exception as e:
print(f"Error: {e}")
After (Rust):
use reqwest;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct User {
name: String,
// other fields...
}
async fn fetch_user(user_id: u32) -> Result<User, reqwest::Error> {
let url = format!("https://api.example.com/users/{}", user_id);
let user = reqwest::get(&url)
.await?
.error_for_status()?
.json::<User>()
.await?;
Ok(user)
}
// Usage
#[tokio::main]
async fn main() {
match fetch_user(123).await {
Ok(user) => println!("User: {}", user.name),
Err(e) => eprintln!("Error: {}", e),
}
}
Key changes:
- Python dict → Rust struct with
serde::Deserialize requests→reqwest(async by default)- Exception handling →
Result<T, E>+?operator async/awaitsyntax is similar in both languages
Example 2: Medium - Configuration Parser with Validation
Before (Python):
from pathlib import Path
from typing import Optional
import json
from dataclasses import dataclass
@dataclass
class Config:
host: str
port: int
timeout: int = 30
def validate(self):
if not (1 <= self.port <= 65535):
raise ValueError(f"Invalid port: {self.port}")
if self.timeout < 0:
raise ValueError(f"Invalid timeout: {self.timeout}")
def load_config(path: Path) -> Config:
"""Load and validate configuration from JSON file."""
if not path.exists():
raise FileNotFoundError(f"Config file not found: {path}")
with path.open() as f:
data = json.load(f)
config = Config(**data)
config.validate()
return config
# Usage
try:
config = load_config(Path("config.json"))
print(f"Server: {config.host}:{config.port}")
except (FileNotFoundError, ValueError, json.JSONDecodeError) as e:
print(f"Configuration error: {e}")
exit(1)
After (Rust):
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use thiserror::Error;
#[derive(Debug, Error)]
enum ConfigError {
#[error("Config file not found: {0}")]
NotFound(String),
#[error("Failed to read config: {0}")]
Io(#[from] std::io::Error),
#[error("Failed to parse config: {0}")]
Parse(#[from] serde_json::Error),
#[error("Invalid port: {0} (must be 1-65535)")]
InvalidPort(u16),
#[error("Invalid timeout: {0} (must be non-negative)")]
InvalidTimeout(i32),
}
#[derive(Debug, Deserialize, Serialize)]
struct Config {
host: String,
port: u16,
#[serde(default = "default_timeout")]
timeout: u32,
}
fn default_timeout() -> u32 {
30
}
impl Config {
fn validate(&self) -> Result<(), ConfigError> {
if self.port == 0 {
return Err(ConfigError::InvalidPort(self.port));
}
// port is u16, so max is already 65535
Ok(())
}
}
fn load_config(path: &Path) -> Result<Config, ConfigError> {
if !path.exists() {
return Err(ConfigError::NotFound(path.display().to_string()));
}
let content = fs::read_to_string(path)?;
let config: Config = serde_json::from_str(&content)?;
config.validate()?;
Ok(config)
}
// Usage
fn main() {
match load_config(Path::new("config.json")) {
Ok(config) => {
println!("Server: {}:{}", config.host, config.port);
}
Err(e) => {
eprintln!("Configuration error: {}", e);
std::process::exit(1);
}
}
}
Key changes:
@dataclass→structwith#[derive(Deserialize)]- Default values via
#[serde(default = "fn")] - Custom error enum with
thiserrorfor better error messages - Port validation simplified via
u16type (0-65535 range enforced by type) - File I/O errors automatically converted via
#[from]
Example 3: Complex - Concurrent Web Scraper with Rate Limiting
Before (Python):
import asyncio
import aiohttp
from typing import List, Dict, Optional
from dataclasses import dataclass
from bs4 import BeautifulSoup
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@dataclass
class Article:
title: str
url: str
excerpt: str
class RateLimiter:
"""Token bucket rate limiter."""
def __init__(self, rate: int, per: float):
self.rate = rate
self.per = per
self.allowance = rate
self.last_check = asyncio.get_event_loop().time()
async def acquire(self):
"""Acquire a token, waiting if necessary."""
current = asyncio.get_event_loop().time()
elapsed = current - self.last_check
self.last_check = current
self.allowance += elapsed * (self.rate / self.per)
if self.allowance > self.rate:
self.allowance = self.rate
if self.allowance < 1.0:
sleep_time = (1.0 - self.allowance) * (self.per / self.rate)
await asyncio.sleep(sleep_time)
self.allowance = 0.0
else:
self.allowance -= 1.0
class Scraper:
def __init__(self, base_url: str, max_concurrent: int = 5, rate_limit: int = 10):
self.base_url = base_url
self.semaphore = asyncio.Semaphore(max_concurrent)
self.rate_limiter = RateLimiter(rate=rate_limit, per=1.0)
async def fetch_page(self, session: aiohttp.ClientSession, url: str) -> Optional[str]:
"""Fetch a single page with rate limiting."""
await self.rate_limiter.acquire()
async with self.semaphore:
try:
logger.info(f"Fetching {url}")
async with session.get(url, timeout=10) as response:
response.raise_for_status()
return await response.text()
except aiohttp.ClientError as e:
logger.error(f"Failed to fetch {url}: {e}")
return None
except asyncio.TimeoutError:
logger.error(f"Timeout fetching {url}")
return None
async def parse_article(self, html: str, url: str) -> Optional[Article]:
"""Parse article from HTML."""
try:
soup = BeautifulSoup(html, 'html.parser')
title = soup.find('h1').get_text(strip=True)
excerpt = soup.find('p').get_text(strip=True)[:200]
return Article(title=title, url=url, excerpt=excerpt)
except Exception as e:
logger.error(f"Failed to parse {url}: {e}")
return None
async def scrape_articles(self, paths: List[str]) -> List[Article]:
"""Scrape multiple articles concurrently."""
async with aiohttp.ClientSession() as session:
tasks = []
for path in paths:
url = f"{self.base_url}{path}"
tasks.append(self.fetch_and_parse(session, url))
results = await asyncio.gather(*tasks)
return [article for article in results if article is not None]
async def fetch_and_parse(self, session: aiohttp.ClientSession, url: str) -> Optional[Article]:
"""Fetch and parse a single article."""
html = await self.fetch_page(session, url)
if html:
return await self.parse_article(html, url)
return None
# Usage
async def main():
scraper = Scraper("https://example.com", max_concurrent=5, rate_limit=10)
paths = [f"/article/{i}" for i in range(20)]
articles = await scraper.scrape_articles(paths)
logger.info(f"Scraped {len(articles)} articles")
for article in articles[:5]:
print(f"{article.title}: {article.excerpt}")
if __name__ == "__main__":
asyncio.run(main())
After (Rust):
use reqwest;
use scraper::{Html, Selector};
use tokio;
use tokio::sync::Semaphore;
use tokio::time::{sleep, Duration, Instant};
use std::sync::Arc;
use thiserror::Error;
use tracing::{info, error};
#[derive(Debug, Clone)]
struct Article {
title: String,
url: String,
excerpt: String,
}
#[derive(Debug, Error)]
enum ScraperError {
#[error("HTTP request failed: {0}")]
Request(#[from] reqwest::Error),
#[error("Failed to parse HTML")]
Parse,
#[error("Timeout")]
Timeout,
}
/// Token bucket rate limiter
struct RateLimiter {
rate: f64,
per: f64,
allowance: tokio::sync::Mutex<(f64, Instant)>,
}
impl RateLimiter {
fn new(rate: usize, per: f64) -> Self {
Self {
rate: rate as f64,
per,
allowance: tokio::sync::Mutex::new((rate as f64, Instant::now())),
}
}
async fn acquire(&self) {
let mut guard = self.allowance.lock().await;
let (mut allowance, mut last_check) = *guard;
let current = Instant::now();
let elapsed = current.duration_since(last_check).as_secs_f64();
last_check = current;
allowance += elapsed * (self.rate / self.per);
if allowance > self.rate {
allowance = self.rate;
}
if allowance < 1.0 {
let sleep_time = (1.0 - allowance) * (self.per / self.rate);
drop(guard); // Release lock before sleeping
sleep(Duration::from_secs_f64(sleep_time)).await;
allowance = 0.0;
} else {
allowance -= 1.0;
}
*guard = (allowance, last_check);
}
}
struct Scraper {
base_url: String,
client: reqwest::Client,
semaphore: Arc<Semaphore>,
rate_limiter: Arc<RateLimiter>,
}
impl Scraper {
fn new(base_url: String, max_concurrent: usize, rate_limit: usize) -> Self {
Self {
base_url,
client: reqwest::Client::new(),
semaphore: Arc::new(Semaphore::new(max_concurrent)),
rate_limiter: Arc::new(RateLimiter::new(rate_limit, 1.0)),
}
}
async fn fetch_page(&self, url: &str) -> Result<String, ScraperError> {
self.rate_limiter.acquire().await;
let _permit = self.semaphore.acquire().await.unwrap();
info!("Fetching {}", url);
let response = tokio::time::timeout(
Duration::from_secs(10),
self.client.get(url).send()
)
.await
.map_err(|_| ScraperError::Timeout)??;
let html = response.error_for_status()?.text().await?;
Ok(html)
}
fn parse_article(&self, html: &str, url: String) -> Result<Article, ScraperError> {
let document = Html::parse_document(html);
let title_selector = Selector::parse("h1").unwrap();
let p_selector = Selector::parse("p").unwrap();
let title = document
.select(&title_selector)
.next()
.ok_or(ScraperError::Parse)?
.text()
.collect::<String>()
.trim()
.to_string();
let excerpt = document
.select(&p_selector)
.next()
.ok_or(ScraperError::Parse)?
.text()
.collect::<String>()
.chars()
.take(200)
.collect();
Ok(Article { title, url, excerpt })
}
async fn fetch_and_parse(&self, url: String) -> Option<Article> {
match self.fetch_page(&url).await {
Ok(html) => {
match self.parse_article(&html, url.clone()) {
Ok(article) => Some(article),
Err(e) => {
error!("Failed to parse {}: {}", url, e);
None
}
}
}
Err(e) => {
error!("Failed to fetch {}: {}", url, e);
None
}
}
}
async fn scrape_articles(&self, paths: &[&str]) -> Vec<Article> {
let tasks: Vec<_> = paths
.iter()
.map(|path| {
let url = format!("{}{}", self.base_url, path);
self.fetch_and_parse(url)
})
.collect();
let results = futures::future::join_all(tasks).await;
results.into_iter().flatten().collect()
}
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let scraper = Scraper::new(
"https://example.com".to_string(),
5, // max_concurrent
10, // rate_limit
);
let paths: Vec<_> = (0..20).map(|i| format!("/article/{}", i)).collect();
let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
let articles = scraper.scrape_articles(&path_refs).await;
info!("Scraped {} articles", articles.len());
for article in articles.iter().take(5) {
println!("{}: {}", article.title, article.excerpt);
}
}
// Cargo.toml dependencies:
// [dependencies]
// reqwest = { version = "0.11", features = ["json"] }
// tokio = { version = "1", features = ["full"] }
// scraper = "0.17"
// thiserror = "1"
// tracing = "0.1"
// tracing-subscriber = "0.3"
// futures = "0.3"
Key changes:
asyncio.Semaphore→tokio::sync::Semaphore(same pattern)- Rate limiter uses
tokio::sync::Mutexfor shared state aiohttp→reqwest(async HTTP client)BeautifulSoup→scrapercrate (HTML parsing)logging→tracing(structured logging)asyncio.gather→futures::future::join_all- Error handling via
Result+thiserrorinstead of exceptions Arc<T>for shared ownership across async tasks- Explicit lifetime management (no GC)
See Also
For more examples and patterns, see:
meta-convert-dev- Foundational patterns with cross-language exampleslang-python-dev- Python development patternslang-rust-dev- Rust development patterns