Skip to content

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

FixDevs · (Updated: )

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

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, &PyDict with 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 &PyAny references 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 &PyT references.
  • 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 the frozen attribute 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 &PyT deprecation 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 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 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 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.
  • #[pyfunction] returns a Vec<Bound<'py, PyAny>> and Python sees a list of opaque objects. You forgot to convert the elements to their concrete Python types. Use PyList::new(py, items) or call .into_py(py) on each element before returning.
  • Free-threaded Python 3.13 build segfaults on import. Your Cargo.toml pins PyO3 below 0.22 but the wheel was built against python3.13t. Bump to PyO3 0.23 or rebuild against the GIL-enabled python3.13 interpreter.
  • PyErr message shows RuntimeError: <unhandled error> instead of your message. You returned a PyErr::new::<PyExc_RuntimeError, _>(msg) with the wrong exception constructor. Use PyRuntimeError::new_err(msg) from pyo3::exceptions — the old generic constructor doesn’t format the message into the exception’s args.
  • Bound<'py, PyDict> won’t Send across an async boundary. Bound types are inherently GIL-tied and can’t cross await points where the GIL might be released. Convert to Py<PyDict> (the GIL-independent reference) before the await, then re-bind with py_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.

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