Guide for creating production-ready PyO3 Rust-Python binding projects. Use when user wants to create a new PyO3 project, write Rust extensions for Python, set up maturin builds, or asks about PyO3 patterns like pyclass, pymethods, NumPy integration, async support, GIL management, type conversions, error handling, or cross-compilation with maturin.
Production-ready engineering guide for PyO3 (Rust-Python bindings) projects. Follow these patterns when creating or modifying PyO3-based projects.
Use the mixed Rust/Python layout (created with maturin new --mixed):
my_project/
├── Cargo.toml
├── pyproject.toml
├── python/
│ └── my_project/
│ ├── __init__.py # Re-exports from compiled Rust module
│ ├── py.typed # PEP 561 marker
│ └── _internal.pyi # Type stubs for Rust module
├── src/
│ ├── lib.rs # #[pymodule] entry point
│ ├── types.rs # #[pyclass] definitions
│ ├── functions.rs # #[pyfunction] definitions
│ └── errors.rs # Custom error/exception types
├── tests/
│ ├── conftest.py
│ └── test_basic.py
└── Cargo.lock
Key principles:
src/, pure Python wrappers in python/.python-source = "python" in pyproject.toml for the mixed layout._internal__init__.pyMaturin is the officially recommended build tool. It handles wheel building, manylinux, ABI3, and cross-compilation.
[package]
name = "my-project"
version = "0.1.0"
edition = "2021"
[lib]
name = "my_project"
crate-type = ["cdylib"] # Required for Python extension
[dependencies]
pyo3 = { version = "0.28", features = ["extension-module"] }
# For ABI3 (one wheel works across Python versions):
# pyo3 = { version = "0.28", features = ["abi3-py310"] }
# For numpy:
# numpy = "0.28"
# For async:
# pyo3-async-runtimes = { version = "0.28", features = ["tokio-runtime"] }
# tokio = { version = "1", features = ["full"] }
[profile.release]
lto = true
codegen-units = 1
strip = true
Notes:
crate-type = ["cdylib"] is mandatory. Add "rlib" only if you need the crate from other Rust code.abi3-py3X generates a single wheel for Python >= 3.X via the stable ABI.PYO3_BUILD_EXTENSION_MODULE automatically, so the extension-module feature is becoming optional.[build-system]
requires = ["maturin>=1.0,<2.0"]
build-backend = "maturin"
[project]
name = "my-project"
version = "0.1.0"
description = "A fast Python extension written in Rust"
requires-python = ">=3.10"
[project.optional-dependencies]
dev = ["pytest>=7", "numpy>=1.24"]
[tool.maturin]
python-source = "python"
features = ["pyo3/extension-module"]
profile = "release"
strip = true
module-name = "my_project._internal"
[tool.pytest.ini_options]
testpaths = ["tests"]
use pyo3::prelude::*;
#[pymodule]
mod my_project {
use super::*;
#[pymodule_export]
use super::Point;
#[pyfunction]
fn add(a: i64, b: i64) -> i64 {
a + b
}
#[pymodule]
mod utils {
use super::*;
#[pyfunction]
fn helper() -> String {
"hello".to_string()
}
}
}
use pyo3::prelude::*;
#[pyclass]
struct Point {
#[pyo3(get, set)]
x: f64,
#[pyo3(get)] // read-only
y: f64,
label: Option<String>, // private, no auto accessor
}
#[pymethods]
impl Point {
#[new]
#[pyo3(signature = (x, y, label=None))]
fn new(x: f64, y: f64, label: Option<String>) -> Self {
Point { x, y, label }
}
#[getter]
fn label(&self) -> Option<&str> {
self.label.as_deref()
}
#[setter]
fn set_label(&mut self, label: Option<String>) {
self.label = label;
}
fn distance(&self, other: &Point) -> f64 {
((self.x - other.x).powi(2) + (self.y - other.y).powi(2)).sqrt()
}
#[staticmethod]
fn origin() -> Point {
Point { x: 0.0, y: 0.0, label: None }
}
#[classmethod]
fn from_tuple(_cls: &Bound<'_, PyType>, coords: (f64, f64)) -> Self {
Point { x: coords.0, y: coords.1, label: None }
}
fn __repr__(&self) -> String {
format!("Point({}, {})", self.x, self.y)
}
}
Note: #[new] combines __new__ + __init__. There is no separate __init__ in PyO3.
#[pyclass(subclass)]
struct Animal {
name: String,
}
#[pyclass(extends=Animal)]
struct Dog {
breed: String,
}
#[pymethods]
impl Dog {
#[new]
fn new(name: String, breed: String) -> (Self, Animal) {
(Dog { breed }, Animal { name })
}
}
| Rust Type | Python Type |
|---|---|
bool | bool |
i32, i64, u32, u64 | int |
f32, f64 | float |
String, &str | str |
Vec<T> | list |
HashMap<K, V> | dict |
HashSet<T> | set |
(A, B, ...) | tuple |
Option<T> | Optional[T] |
Bound<'py, PyAny> | any Python object |
Py<T> | owned reference (GIL-independent) |
use pyo3::prelude::*;
#[derive(FromPyObject)]
struct Config {
host: String,
port: u16,
#[pyo3(attribute("verbose"))]
debug: bool,
}
// Enum extraction: tries each variant in order
#[derive(FromPyObject)]
enum StringOrInt {
String(String),
Int(i64),
}
#[derive(IntoPyObject)]
struct Result {
status: String,
code: i32,
}
// Converts to Python dict: {"status": "ok", "code": 200}
Note: IntoPy and ToPyObject are deprecated since PyO3 0.23. Use IntoPyObject instead.
use pyo3::prelude::*;
use pyo3::exceptions::PyValueError;
#[pyfunction]
fn parse_int(s: &str) -> PyResult<i64> {
s.parse::<i64>().map_err(|e| PyValueError::new_err(e.to_string()))
}