Fix: PyO3 Not Working — Bound API Migration, GIL Acquisition, Error Conversion, and NumPy Interop
Quick Answer
How to fix PyO3 errors — &PyAny vs Bound<PyAny> migration, GIL acquire/release patterns, returning Rust errors as Python exceptions, numpy ndarray zero-copy, pyclass frozen, and async tokio integration.
The Error
You upgrade PyO3 from 0.20 to 0.22 and your code stops compiling:
#[pyfunction]
fn add(py: Python, list: &PyList) -> PyResult<i64> {
// error[E0277]: the trait bound `&PyList: PyTypeCheck` is not satisfied
}Or your function returns a Rust Result<T, E> and Python sees a pyo3_runtime.PanicException:
pyo3_runtime.PanicException: called `Result::unwrap()` on an `Err` value:
SomeError("..."), src/lib.rs:42:14Or you parallelize with Rayon and the program deadlocks:
#[pyfunction]
fn process(py: Python, items: Vec<i64>) -> Vec<i64> {
items.par_iter().map(|x| {
Python::with_gil(|py| { // Deadlock!
// ...
})
}).collect()
}Or your NumPy interop copies a 2 GB array on every call:
#[pyfunction]
fn process_array(arr: &PyArray2<f64>) -> &PyArray2<f64> {
let array = arr.readonly(); // Allocates a fresh array?
}Why This Happens
PyO3 0.21+ moved to a new Bound API built around Bound<'py, T> smart pointers. The old &PyAny, &PyList, Python<'py> lifetime soup is being replaced with one consistent pattern: every Python object is held as a Bound<'py, T>, which captures both the GIL lifetime and the type.
Three sources of pain in real projects:
- API migration is mechanical but pervasive. Almost every function signature changes. The Bound API is more correct (especially around object lifetimes), but moving a 5000-line PyO3 codebase touches every file.
- GIL contention. PyO3 calls don’t release the GIL automatically. If you spawn Rust threads and they try to re-acquire the GIL, you serialize them — the parallelism evaporates.
- Error handling needs explicit conversion. Rust’s
?operator works only if your error type implementsFrom<...> for PyErr. Without it, you either returnStringerrors or use.unwrap()and crash Python. - NumPy zero-copy depends on the right traits.
PyReadonlyArray2<f64>borrows the buffer;.as_array()gives you anArrayViewwithout copying. Usingto_vec()orto_owned_array()copies.
Fix 1: Migrate to the Bound API
Old style (0.20 and earlier):
use pyo3::prelude::*;
use pyo3::types::{PyList, PyDict};
#[pyfunction]
fn process(py: Python, list: &PyList) -> PyResult<&PyDict> {
let result = PyDict::new(py);
for item in list.iter() {
result.set_item(item, item)?;
}
Ok(result)
}New style (0.22+):
use pyo3::prelude::*;
use pyo3::types::{PyList, PyDict};
#[pyfunction]
fn process<'py>(py: Python<'py>, list: &Bound<'py, PyList>) -> PyResult<Bound<'py, PyDict>> {
let result = PyDict::new(py); // Returns Bound<'py, PyDict>
for item in list.iter() {
result.set_item(&item, &item)?;
}
Ok(result)
}Key changes:
&PyList→&Bound<'py, PyList>(or ownedBound<'py, PyList>).&PyDictreturns →Bound<'py, PyDict>(no leading&).'pylifetime is now explicit and uniform across types.
For &PyAny (the old “any Python object”):
// Old:
fn handler(any: &PyAny) -> PyResult<()> { ... }
// New:
fn handler<'py>(any: &Bound<'py, PyAny>) -> PyResult<()> { ... }Pro Tip: The PyO3 migration guide ships a long list of search-and-replace patterns. For large codebases, write a script that does the mechanical rewrites first, then fix the residual errors by hand. The compiler tells you exactly what’s wrong.
Fix 2: Implement From for Your Error Types
For ? to work in PyResult-returning functions, your error must convert to PyErr:
use pyo3::exceptions::{PyValueError, PyIOError};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum MyError {
#[error("invalid input: {0}")]
InvalidInput(String),
#[error("io: {0}")]
Io(#[from] std::io::Error),
}
impl From<MyError> for PyErr {
fn from(err: MyError) -> PyErr {
match err {
MyError::InvalidInput(msg) => PyValueError::new_err(msg),
MyError::Io(e) => PyIOError::new_err(e.to_string()),
}
}
}
#[pyfunction]
fn read_file(path: &str) -> PyResult<String> {
Ok(std::fs::read_to_string(path).map_err(MyError::Io)?)
}Now Python sees a proper ValueError or IOError, not a PanicException.
Common Mistake: Returning Result<T, String> from a #[pyfunction]. Python sees this as a successful return with a tuple of (Ok|Err, value). Always use PyResult<T> (which is Result<T, PyErr>).
Fix 3: Release the GIL Before Heavy Computation
The GIL serializes all Python bytecode. If your Rust function holds it during CPU-bound work, multi-threaded Python callers see no speedup. Release it with Python::allow_threads:
use pyo3::prelude::*;
#[pyfunction]
fn heavy_compute(py: Python, data: Vec<f64>) -> Vec<f64> {
py.allow_threads(|| {
// No Python access inside this closure — safe to release GIL.
data.into_iter().map(|x| expensive_fn(x)).collect()
})
}allow_threads releases the GIL for the duration of the closure. Other Python threads can run. The closure can’t touch Python objects (the type system enforces it).
For data that lives in Python (lists, numpy arrays), do the conversion to native Rust types before calling allow_threads:
#[pyfunction]
fn process_list(py: Python, list: &Bound<PyList>) -> Vec<f64> {
// Extract into Vec while holding the GIL:
let data: Vec<f64> = list.extract().unwrap();
// Compute without the GIL:
py.allow_threads(|| data.into_iter().map(square).collect())
}Pro Tip: Benchmark with and without allow_threads. For functions that take <100µs, the GIL release/acquire overhead may cost more than you save.
Fix 4: Avoid GIL Acquisition Inside Rayon
When you par_iter inside a #[pyfunction], each Rayon thread already lacks the GIL. Calling Python::with_gil inside them serializes them again:
// SLOW: every thread waits for the GIL.
items.par_iter().map(|x| {
Python::with_gil(|py| { /* ... */ })
}).collect()The fix: keep Rayon iterations Python-free. Extract everything you need into Rust types up front:
#[pyfunction]
fn process(py: Python, items: Vec<f64>) -> Vec<f64> {
py.allow_threads(|| {
items.par_iter().map(|x| pure_rust_compute(*x)).collect()
})
}If you genuinely need Python objects from each thread (rare, usually a sign of bad architecture), reconsider — maybe call back into Python from a single thread after the parallel compute.
Fix 5: NumPy Zero-Copy
For NumPy arrays, the numpy crate provides PyReadonlyArrayN<T> and PyArrayN<T>:
use ndarray::Array2;
use numpy::{PyArray2, PyArrayMethods, PyReadonlyArray2, ToPyArray};
use pyo3::prelude::*;
#[pyfunction]
fn sum_rows<'py>(
py: Python<'py>,
arr: PyReadonlyArray2<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let view = arr.as_array(); // Zero-copy view
let result: Array2<f64> = view.sum_axis(ndarray::Axis(1)).insert_axis(ndarray::Axis(1));
Ok(result.to_pyarray(py))
}Two patterns:
PyReadonlyArrayN<T>for input you don’t mutate..as_array()gives anArrayView— zero-copy.Bound<PyArrayN<T>>for output..to_pyarray(py)materializes a fresh NumPy array.
For in-place mutation of the input (rare, careful with aliasing):
use numpy::PyReadwriteArray2;
#[pyfunction]
fn scale_inplace<'py>(arr: PyReadwriteArray2<'py, f64>, factor: f64) -> PyResult<()> {
let mut arr = arr.as_array_mut();
arr.mapv_inplace(|x| x * factor);
Ok(())
}Common Mistake: Calling .to_vec() on a 2D array’s .as_array(). That allocates a copy. Use ndarray’s operations on the view directly.
Fix 6: #[pyclass] and #[pymethods]
For Python-callable classes:
#[pyclass]
struct Counter {
count: u64,
}
#[pymethods]
impl Counter {
#[new]
fn new() -> Self {
Counter { count: 0 }
}
fn increment(&mut self, by: u64) {
self.count += by;
}
fn get(&self) -> u64 {
self.count
}
fn __repr__(&self) -> String {
format!("Counter(count={})", self.count)
}
}Add to the module:
#[pymodule]
fn my_pkg(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<Counter>()?;
Ok(())
}For immutable classes (sometimes faster, hashable, can be used as dict keys):
#[pyclass(frozen)]
struct Point {
x: f64,
y: f64,
}frozen disables setters and removes interior mutability checks.
Pro Tip: For classes accessed concurrently from Python threads, prefer frozen plus Arc<Mutex<...>> over PyO3’s default interior mutability. Easier to reason about and matches what concurrent Rust code would do anyway.
Fix 7: Async with pyo3-async-runtimes
PyO3 doesn’t natively support async Rust functions exposed to Python. Use pyo3-async-runtimes (formerly pyo3-asyncio):
[dependencies]
pyo3 = "0.22"
pyo3-async-runtimes = { version = "0.22", features = ["tokio-runtime"] }
tokio = { version = "1", features = ["full"] }use pyo3::prelude::*;
use pyo3_async_runtimes::tokio::future_into_py;
#[pyfunction]
fn fetch_url<'py>(py: Python<'py>, url: String) -> PyResult<Bound<'py, PyAny>> {
future_into_py(py, async move {
let body = reqwest::get(&url).await?.text().await?;
Ok(body)
})
}From Python:
import asyncio
import my_pkg
async def main():
body = await my_pkg.fetch_url("https://example.com")
print(body[:100])
asyncio.run(main())future_into_py converts a Rust Future into a Python awaitable. Errors propagate as Python exceptions.
Fix 8: Building and Linking
For development, use maturin develop from the venv:
python -m venv .venv
source .venv/bin/activate
pip install maturin
maturin develop --release
python -c "import my_pkg; print(my_pkg.add(1, 2))"For distribution, build wheels per platform — see Maturin not working for the build matrix.
Common Mistake: Forgetting crate-type = ["cdylib"] in Cargo.toml. PyO3 needs a dynamic library, not a binary:
[lib]
name = "my_pkg"
crate-type = ["cdylib"]
[dependencies]
pyo3 = { version = "0.22", features = ["extension-module"] }The extension-module feature stops PyO3 from linking against libpython, which makes the wheel relocatable across Python installs.
Still Not Working?
A few less-obvious failures:
AttributeError: module has no attribute X. The function exists but wasn’t registered withm.add_function(wrap_pyfunction!(X, m)?)?. The compile succeeded; the runtime registration is missing.- Segfault on Python exit. A static Rust value held a Python reference past interpreter shutdown. Avoid
lazy_static!withPyObject— initialize per-call or useOnceLockwith explicit drop. PanicExceptioninstead of a real exception. Somewhere your code panics (unwrap(),expect(), index out of bounds). Replace with?and proper error conversion.- Build fails with
linking with cc failed. Missing system Python dev headers. Installpython3-dev(Debian/Ubuntu),python3-devel(Fedora/RHEL), or use the maturin Docker image for Linux. extension-modulecauses failed imports on macOS. The undefined-symbol behavior differs. Maturin/PyO3 handle this via rpath flags — make sure you’re building with maturin, notcargo builddirectly.- Slow Python ↔ Rust boundary calls. Each call has overhead (~1µs). For tight loops, batch the work into one Rust call rather than many small ones.
#[pyclass]field can’t be sent across threads. PyO3 enforcesSend + Syncfor fields by default. For non-Sendtypes, wrap inMutexor mark the class#[pyclass(unsendable)](and accept the runtime check).- NumPy integer types don’t extract cleanly. Python
intis unbounded; NumPyint64is fixed. Usei64for the Rust side and letPyAny::extractconvert.
For related Rust-Python interop and packaging issues, see Maturin not working, Python packaging not working, Rust trait not implemented, and pip could not build wheels.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Maturin Not Working — develop Errors, ABI3 Wheels, manylinux, and macOS Universal Builds
How to fix Maturin errors — maturin develop fails outside venv, abi3 forward compatibility, manylinux wheel auditing, macOS universal2 cross-compile, pyproject.toml vs Cargo.toml conflicts, and PyO3 feature flags.
Fix: scalene Not Working — Web UI, GPU Profiling, and AI Suggestion Errors
How to fix scalene errors — scalene command not found, web UI port conflict, no GPU detected, profile.json empty, AI optimize requires OpenAI key, native code not attributed, and Jupyter integration.
Fix: py-spy Not Working — Attach Permission, Empty Output, and Native Frame Errors
How to fix py-spy errors — Operation not permitted ptrace, flamegraph blank, missing native code frames, top mode shows no Python frames, dump command empty, and subprocess inheritance.
Fix: memray Not Working — Tracking Errors, Flamegraph Empty, and Native Allocations
How to fix memray errors — memray run command not found, flamegraph shows no data, native allocations not tracked, live mode TUI broken, attach to running process fails, and pytest integration.