Skip to main content

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 following impl block 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 you print() or repr() 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

AspectRust–PyO3Native Python
CompilationCompiled to native code; statically typedInterpreted; dynamically typed
Speed10–100× faster for compute-heavy methodsSuitable for I/O and logic
MemoryExplicit struct layout; no GC overheadReference-counted; GC for cycles
DevelopmentMore verbose; Rust compiler is strictQuick iteration; dynamic behavior
Use casePerformance-critical classesRapid 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.

Further Reading