Skip to content

Fix: PyO3 Not Working — Bound API Migration, GIL Acquisition, Error Conversion, and NumPy Interop

FixDevs ·

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:14

Or 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 implements From<...> for PyErr. Without it, you either return String errors or use .unwrap() and crash Python.
  • NumPy zero-copy depends on the right traits. PyReadonlyArray2<f64> borrows the buffer; .as_array() gives you an ArrayView without copying. Using to_vec() or to_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 owned Bound<'py, PyList>).
  • &PyDict returns → Bound<'py, PyDict> (no leading &).
  • 'py lifetime 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 an ArrayView — 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 with m.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! with PyObject — initialize per-call or use OnceLock with explicit drop.
  • PanicException instead 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. Install python3-dev (Debian/Ubuntu), python3-devel (Fedora/RHEL), or use the maturin Docker image for Linux.
  • extension-module causes failed imports on macOS. The undefined-symbol behavior differs. Maturin/PyO3 handle this via rpath flags — make sure you’re building with maturin, not cargo build directly.
  • 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 enforces Send + Sync for fields by default. For non-Send types, wrap in Mutex or mark the class #[pyclass(unsendable)] (and accept the runtime check).
  • NumPy integer types don’t extract cleanly. Python int is unbounded; NumPy int64 is fixed. Use i64 for the Rust side and let PyAny::extract convert.

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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles