Fix: PyO3 Not Working — Bound API Migration, GIL Acquisition, Error Conversion, and NumPy Interop
Part of: Python Errors
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.
The second category of failures is build-time. PyO3 has a build script (pyo3-build-config) that probes the active Python to determine the ABI, version, and platform. If it can’t find Python or finds the wrong one — common in Docker images where you’ve installed Python via pyenv alongside the system python3 — the build fails before any of your code compiles. The error usually reads error: failed to run custom build command for 'pyo3-build-config' with a vague follow-up. Set PYO3_PYTHON to the exact interpreter path you want PyO3 to bind against, or use PYO3_NO_PYTHON during cross-compilation when you genuinely don’t have a target Python to probe.
The third category — and the one most likely to bite you on a Python upgrade — is the Python 3.13 free-threaded build. CPython 3.13 added an experimental no-GIL mode (PEP 703). PyO3 0.22 added partial support, 0.23 stabilized it. If you build against a free-threaded Python with an old PyO3, the build silently links against libpython3.13t and your extension produces undefined behavior at runtime because PyO3’s internal GIL-tracking is wrong for that interpreter. Always check python -c "import sys; print(sys._is_gil_enabled())" on the target interpreter and confirm your PyO3 version supports that mode.
Version History That Changes the Failure Mode
PyO3’s API has changed enough between releases that an error message in your codebase usually maps to a specific version transition. The reference points:
- 0.19 (mid 2023): Last release before the Bound API existed. Used
&PyAny,&PyList,&PyDictwith implicit lifetimes tied to the GIL token. Tutorials and Stack Overflow answers from this era do not compile on 0.22+. - 0.20 (Dec 2023): Introduced
Bound<'py, T>as the new owning smart pointer but kept the old&PyAnyreferences working. This is the migration starting point: old code still compiles, new code can use the new types alongside. - 0.21 (Mar 2024): GIL-bound type-system rework.
Python<'py>became more central;Py<T>(the GIL-independent reference) was clarified as the way to hold Python objects across GIL release points. Deprecation warnings landed for the legacy&PyTreferences. - 0.22 (Aug 2024): First release with experimental Python 3.13 free-threaded support. Bound API became the strongly preferred path; the migration guide formalized the search-and-replace rules. Maturin 1.7+ is required to build cleanly.
- 0.23 (early 2025): Free-threaded Python support stabilized.
#[pyclass]gained thefrozenattribute as a first-class supported pattern for shared-state classes that need to survive across threads without the GIL. - 0.24+ (mid 2025): GIL-independent
Py<T>got ergonomic improvements; the legacy&PyTdeprecation warnings became hard errors in most contexts.
When you read a PyO3 example, the very first thing to do is locate which version it targets. A “fine on Stack Overflow” snippet from 2023 will fail to compile on a current Cargo.toml that pins 0.22 or later.
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 with Maturin and a CI matrix that covers Linux (manylinux), macOS (universal2), and Windows.
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. #[pyfunction]returns aVec<Bound<'py, PyAny>>and Python sees a list of opaque objects. You forgot to convert the elements to their concrete Python types. UsePyList::new(py, items)or call.into_py(py)on each element before returning.- Free-threaded Python 3.13 build segfaults on import. Your
Cargo.tomlpins PyO3 below 0.22 but the wheel was built againstpython3.13t. Bump to PyO3 0.23 or rebuild against the GIL-enabledpython3.13interpreter. PyErrmessage showsRuntimeError: <unhandled error>instead of your message. You returned aPyErr::new::<PyExc_RuntimeError, _>(msg)with the wrong exception constructor. UsePyRuntimeError::new_err(msg)frompyo3::exceptions— the old generic constructor doesn’t format the message into the exception’sargs.Bound<'py, PyDict>won’tSendacross an async boundary. Bound types are inherently GIL-tied and can’t crossawaitpoints where the GIL might be released. Convert toPy<PyDict>(the GIL-independent reference) before theawait, then re-bind withpy_dict.bind(py)after.
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.