PyO3 Classes: How to Wrap Rust Structs
While functions are useful for stateless computation, most real-world code operates on objects with state and behavior. A PyO3 class is a Rust struct annotated with #[pyclass] that Python can instantiate, call methods on, and access properties of—exactly like a native Python class. This article teaches you to design Rust structs that expose cleanly to Python, implement constructors and methods, add properties, and manage object lifecycle. By the end, you will author stateful extensions that feel natural to both languages.
PyO3 classes bridge the impedance mismatch between Rust's ownership model and Python's reference counting. The key is understanding how PyO3 holds a reference to your struct, manages memory, and allows Python to safely call Rust methods without violating Rust's borrowing rules.
Basic Class Definition: Struct to Python Class
A Rust struct becomes a Python class by adding the #[pyclass] macro. Here is a simple example—a Point struct with x and y coordinates:
use pyo3::prelude::*;
#[pyclass]
struct Point {
x: f64,
y: f64,
}
#[pymethods]
impl Point {
#[new]
fn new(x: f64, y: f64) -> Self {
Point { x, y }
}
fn distance_to_origin(&self) -> f64 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
fn __repr__(&self) -> String {
format!("Point({}, {})", self.x, self.y)
}
}
#[pymodule]
fn geometry(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<Point>()?;
Ok(())
}
From Python:
from geometry import Point
p = Point(3.0, 4.0)
print(p) # Output: Point(3.0, 4.0)
print(p.distance_to_origin()) # Output: 5.0
Here is what each decorator does:
#[pyclass]marks the struct as a Python class.#[pymethods]signals that the followingimplblock contains methods exposed to Python.#[new]is a special method that acts as the constructor (__init__in Python).__repr__is a dunder method that Python calls when youprint()orrepr()an object.
Adding Properties with #[getter] and #[setter]
Properties allow Python code to access struct fields using attribute syntax (point.x) rather than method calls. The #[getter] and #[setter] attributes expose private fields:
#[pyclass]
struct Circle {
#[pyo3(get, set)]
x: f64,
#[pyo3(get, set)]
y: f64,
radius: f64,
}
#[pymethods]
impl Circle {
#[new]
fn new(x: f64, y: f64, radius: f64) -> Self {
Circle { x, y, radius }
}
#[getter]
fn radius(&self) -> f64 {
self.radius
}
#[setter]
fn set_radius(&mut self, radius: f64) {
self.radius = radius;
}
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius.powi(2)
}
}
#[pymodule]
fn shapes(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<Circle>()?;
Ok(())
}
From Python:
from shapes import Circle
circle = Circle(0.0, 0.0, 5.0)
print(circle.radius) # Output: 5.0
print(circle.area()) # Output: 78.53981633974483
circle.radius = 10.0
print(circle.area()) # Output: 314.1592653589793
The #[pyo3(get, set)] attribute on a field automatically generates a getter and setter. For finer control, use explicit #[getter] and #[setter] methods, as shown for radius.
Instance Methods, Class Methods, and Static Methods
PyO3 supports three method types. Instance methods receive &self or &mut self; class methods receive &Bound<PyType>; static methods receive no implicit receiver:
use pyo3::prelude::*;
#[pyclass]
struct Counter {
count: i32,
}
#[pymethods]
impl Counter {
#[new]
fn new() -> Self {
Counter { count: 0 }
}
fn increment(&mut self) {
self.count += 1;
}
fn get_count(&self) -> i32 {
self.count
}
#[classmethod]
fn from_value(_cls: &Bound<PyType>, value: i32) -> Self {
Counter { count: value }
}
#[staticmethod]
fn version() -> String {
"1.0.0".to_string()
}
}
#[pymodule]
fn counter_ext(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<Counter>()?;
Ok(())
}
From Python:
from counter_ext import Counter
# Instance method
c = Counter()
c.increment()
print(c.get_count()) # Output: 1
# Class method
c2 = Counter.from_value(10)
print(c2.get_count()) # Output: 10
# Static method
print(Counter.version()) # Output: "1.0.0"
Ownership and Mutable References
PyO3 enforces Rust's borrowing rules at the Python boundary. You cannot hold two mutable references to the same object simultaneously. If you need to mutate, use &mut self:
#[pyclass]
struct Wallet {
balance: f64,
}
#[pymethods]
impl Wallet {
#[new]
fn new(initial_balance: f64) -> Self {
Wallet { balance: initial_balance }
}
fn deposit(&mut self, amount: f64) {
self.balance += amount;
}
fn balance(&self) -> f64 {
self.balance
}
}
From Python:
from wallet_ext import Wallet
w = Wallet(100.0)
w.deposit(50.0)
print(w.balance()) # Output: 150.0
If you call balance() (immutable) and deposit() (mutable) concurrently from Python threads, PyO3 acquires the GIL appropriately and ensures Rust's invariants are maintained.
Comparison with Native Python Classes
| Aspect | Rust–PyO3 | Native Python |
|---|---|---|
| Compilation | Compiled to native code; statically typed | Interpreted; dynamically typed |
| Speed | 10–100× faster for compute-heavy methods | Suitable for I/O and logic |
| Memory | Explicit struct layout; no GC overhead | Reference-counted; GC for cycles |
| Development | More verbose; Rust compiler is strict | Quick iteration; dynamic behavior |
| Use case | Performance-critical classes | Rapid prototyping, glue code |
Key Takeaways
#[pyclass]on a struct makes it instantiable from Python;#[pymethods]implements methods.- The
#[new]method acts as the constructor;__repr__and other dunders work as expected. #[getter]and#[setter]or#[pyo3(get, set)]expose struct fields as properties.#[classmethod]and#[staticmethod]allow factory constructors and utility functions.- PyO3 enforces Rust's borrowing rules at the boundary; mutable methods acquire locks as needed.
- Rust classes are 10–100× faster than pure Python for compute-heavy operations.
Frequently Asked Questions
Can a PyO3 class inherit from another Rust class?
PyO3 does not support direct Rust inheritance (Rust uses composition instead). However, you can nest structs or use trait objects. For Python-side inheritance, see Article 9 (advanced protocols).
Can Python code subclass a PyO3 class?
Yes. Python can subclass a PyO3 class and override methods. PyO3 handles the dispatching transparently. See the PyO3 documentation on inheritance for details.
What if I need to hold references to other PyO3 objects inside my struct?
Use Py<T> or PyObject to hold a reference-counted pointer to another Python object. This avoids lifetime issues and ensures correct memory management. Example: field: Py<SomeClass>.
How do I implement __init__ separately from __new__?
In Rust, #[new] is the only constructor. If you need two-phase initialization, use a factory method (a #[classmethod]) or expose a separate init method. Python will call your #[new] method and expect full initialization.
Can I make a field private (hidden from Python)?
Yes. Fields not annotated with #[pyo3(get, set)] are private to Rust. Expose only the fields and methods you want via explicit #[getter] and #[setter] decorators, or do not decorate them at all.