PyO3 bindings for spatial operations

The exact problem this page solves: a Python gateway process needs a CPU-bound geometry batch (containment, distance, or transform) to run at native speed without hand-writing C, without a libffi trampoline on every call, and without the memory-safety bugs a raw C binding invites when the coordinate arrays are large and long-lived. Within the Core Edge GIS Fundamentals guide, this is the Rust-based path introduced alongside ctypes and cffi in Python/C FFI for hot-path geometry: PyO3 generates the CPython extension module directly from Rust source, so there is no separate shared object to load and no argument types to declare by hand. The deployment target is the same one as the rest of this guide — an aarch64 Linux gateway, 256 MB–2 GB of RAM, cross-compiled from an x86_64 build host.

Binding selection rationale

PyO3 trades a heavier build toolchain for two things ctypes and cffi cannot offer on their own: compile-time signature checking, and Rust’s borrow checker ruling out use-after-free and data races in the binding code itself. A hand-written C extension can dereference a stale pointer after Python garbage-collects the object backing it; the equivalent PyO3 function either fails to compile or, if it does compile, is provably free of that class of bug, because the ownership of every buffer crossing the boundary is tracked by the type system rather than by convention.

The other structural advantage is py.allow_threads. Releasing the GIL in a hand-written C extension means remembering to wrap the call in Py_BEGIN_ALLOW_THREADS/Py_END_ALLOW_THREADS and never touching a Python object in between — an easy rule to violate silently, since nothing stops a stray call back into the interpreter while the GIL is supposedly released. PyO3’s allow_threads takes a closure and the compiler enforces, in the general case, that the closure isn’t holding onto GIL-bound state it doesn’t own; for CPU-bound geometry over a batch of coordinates, that is exactly the shape of code being written anyway.

The cost is real: maturin, a Rust toolchain, and a cross-compilation target replace what ctypes needed (nothing) or what cffi API mode needed (a C compiler). For a gateway team already comfortable with C, PyO3 is worth adopting specifically when call rates are high enough that the libffi trampoline shows up in a profile, or when the binding surface is large enough that memory-safety bugs in hand-written glue code become a real risk.

Call path through a PyO3 extension with the GIL released A Python caller invokes a PyO3 pyfunction while holding the GIL. The function borrows numpy array views without copying, then releases the GIL through py.allow_threads while Rust computes the batch across all available cores, and finally reacquires the GIL to return a plain Python list back to the caller. Python call holds the GIL #[pyfunction] borrows array views allow_threads GIL released Rust compute batch over cores Return list GIL reacquired
The GIL is only held while borrowing input and building the return value, not while Rust does the work.

The complete binding

The module below exposes a batch bounding-circle test as a native Python function, taking two numpy float64 arrays by reference rather than copying them into Rust-owned Vecs, and running the computation with the GIL released:

// src/lib.rs
use pyo3::prelude::*;
use numpy::PyReadonlyArray1;

/// Batch containment test against a circle, called from Python as
/// geofence_rs.contains_batch(xs, ys, cx, cy, r) -> list[bool]
#[pyfunction]
fn contains_batch(
    py: Python<'_>,
    xs: PyReadonlyArray1<f64>,
    ys: PyReadonlyArray1<f64>,
    cx: f64,
    cy: f64,
    r: f64,
) -> Vec<bool> {
    // as_array() borrows the numpy buffer directly; no copy into a Rust Vec.
    let xs = xs.as_array();
    let ys = ys.as_array();

    // Release the GIL for the CPU-bound portion. The closure only touches
    // plain f64 slices, never a Python object, so nothing here needs the GIL.
    py.allow_threads(|| {
        let r2 = r * r;
        xs.iter()
            .zip(ys.iter())
            .map(|(&x, &y)| {
                let dx = x - cx;
                let dy = y - cy;
                dx * dx + dy * dy <= r2
            })
            .collect()
    })
}

#[pymodule]
fn geofence_rs(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(contains_batch, m)?)?;
    Ok(())
}

The Cargo.toml pins the extension-module feature and the numpy crate that provides PyReadonlyArray1:

[package]
name = "geofence_rs"
version = "0.1.0"
edition = "2021"

[lib]
name = "geofence_rs"
crate-type = ["cdylib"]

[dependencies]
pyo3 = { version = "0.21", features = ["extension-module"] }
numpy = "0.21"

From the Python side, the compiled module is imported like any other extension — there is no runtime binding step, no argtypes, and no shared-object path to manage:

import numpy as np
import geofence_rs

xs = np.array([1.0002, 0.998, 1.05], dtype=np.float64)
ys = np.array([1.0001, 1.001, 0.97], dtype=np.float64)

hits = geofence_rs.contains_batch(xs, ys, 1.0, 1.0, 0.01)

Building and cross-compiling with maturin

maturin builds the extension module and produces a wheel that CPython can import directly. For local development against the build host’s own architecture:

pip install maturin
maturin develop --release

Cross-compiling for an aarch64 gateway from an x86_64 build server needs the aarch64 Rust target installed and a linker that can produce the right binaries; maturin’s --zig flag uses the Zig toolchain as a portable cross-linker so the build server doesn’t need a full aarch64 sysroot installed:

rustup target add aarch64-unknown-linux-gnu
pip install maturin[zig]
maturin build --release --target aarch64-unknown-linux-gnu --zig

The resulting wheel in target/wheels/ is tied to a specific CPython ABI and target architecture, so it belongs in the same image-build pipeline that produces the rest of the gateway’s Python environment — not something installed ad hoc on a running device.

Cross-compiling a PyO3 extension for an aarch64 gateway with maturin The build pipeline from left to right: the Rust source and Cargo manifest feed maturin, which invokes the Rust compiler with the aarch64 unknown Linux GNU target and a linker from the target sysroot, producing an abi3 wheel. The wheel is baked into the Yocto image rather than installed on the device, so the gateway itself never needs a compiler. A side branch shows the check that catches most failures: verifying the wheel's declared ABI tag against the interpreter shipped in the image. lib.rs + Cargo.toml crate-type cdylib maturin build --target aarch64…gnu rustc + linker from the target sysroot abi3 wheel one build, many 3.x Yocto image no compiler on the gateway gate the build: wheel ABI tag == image interpreter a mismatch fails at import, hours after a green build The toolchain lives in CI; the device only ever sees a wheel
Every arrow here happens on the build host. The gateway's side of the contract is a single import, which is exactly why the ABI check belongs before the image is assembled.

Constraint validation table

Constraint Expected impact Mitigation built into the code
RAM PyReadonlyArray1 borrows the numpy buffer instead of copying it into a Rust Vec, avoiding a second full-size allocation as_array() gives a zero-copy view; only the output Vec<bool> is newly allocated
CPU / GIL contention Without allow_threads, a large batch blocks every other coroutine and thread for the call’s full duration py.allow_threads releases the GIL for the entire compute loop, which touches no Python state
Build/deploy complexity Cross-compiling a Rust extension needs a target toolchain the build server may not have by default maturin build --zig cross-links for aarch64 without installing a full target sysroot
Power Native, allocation-light Rust code spends fewer cycles per point than an interpreted equivalent, directly reducing active time on a duty-cycled core The compute loop is a plain iterator chain with no heap allocation per point

Gotchas and edge cases

  • Don’t call back into Python inside allow_threads. The closure passed to allow_threads must not touch any Py<T>, Bound<'_, T>, or other GIL-bound handle — doing so is either a compile error or undefined behavior depending on how it’s smuggled in. Keep the closure operating purely on primitive slices, exactly as in the example above.
  • PyReadonlyArray1 borrows, it doesn’t own. The borrow is only valid for the duration of the function call; nothing about it is safe to stash in a struct and reuse across calls. If a reference layer needs to persist across calls, copy it once at load time into a Rust-owned buffer, not per request.
  • ABI tags are strict. A wheel built for cp311-manylinux_2_17_aarch64 will not import under a different CPython minor version or a musl-based root filesystem; build one wheel per (Python version, libc) combination actually running in the fleet, or standardize the fleet to fewer combinations.

What the borrow checker buys at the boundary

The safety claim for PyO3 is narrower than “Rust is safe”, and it is worth stating precisely because it is the main reason to accept the heavier toolchain. When the binding accepts a NumPy array as PyReadonlyArray1<f64>, the array’s Python reference is held for exactly as long as the Rust view exists, and the compiler refuses to let that view outlive the call. The class of bug this removes — a C function that stashes a pointer to a buffer Python then frees — is the single most common cause of a segfault that reproduces only under memory pressure, because that is when the freed page is actually reused by something else.

Buffer lifetime across the boundary, unchecked in C versus borrow-checked in Rust Two timelines. In the C binding the array is allocated, a raw pointer is passed in and retained by the extension, Python drops its last reference and the allocator reuses the page, and the next read through the retained pointer returns another object's bytes. In the PyO3 binding the readonly array view holds the Python reference for the duration of the call, so the drop cannot happen while the view is alive and the compiler rejects any attempt to keep the view past the call. The same mistake, attempted twice raw C pointer array allocated pointer retained by C refcount hits zero read returns another object's bytes PyReadonlyArray array allocated view alive — Python reference held for its whole lifetime retaining it past here does not compile The drop in the top row is not a bug in the C code — it is correct Python. The bug is that nothing connected the two lifetimes. Cost of the guarantee: a Rust cross-compiler in CI. Cost of the alternative: a segfault that only appears under memory pressure.
The borrow is the whole product. Everything else PyO3 offers — direct calls, compile-time signatures — is available from cffi API mode as well.

None of this extends past the call boundary on its own. If the extension genuinely needs to keep data between calls — a prepared geometry, a cached index — copy it into a Rust-owned structure at construction time and let Python’s array go; a Vec<f64> the extension owns has no lifetime relationship with the interpreter and cannot be invalidated by it.

  • extension-module feature changes linking. Forgetting the extension-module feature on pyo3 in Cargo.toml produces a binary that tries to statically link the whole Python interpreter — it will build but fail to import as an extension. This is the single most common first-build mistake with PyO3.
  • Numeric precision at the boundary. numpy float64 arrays and Rust f64 are bit-identical, so there is no marshalling precision loss here — unlike a binding that narrows to float/f32 at some point in the chain, which silently changes containment verdicts on points near a boundary.

Integration with the gateway pipeline

Load the compiled module once at worker start, the same pattern used for ctypes and cffi bindings, so the import cost and any Rust-side static initialization happen outside the hot loop:

def worker_init():
    global _geofence
    import geofence_rs
    _geofence = geofence_rs

def process_chunk(xs, ys, cx, cy, r):
    return _geofence.contains_batch(xs, ys, cx, cy, r)

Because allow_threads already frees the GIL for the compute-heavy portion, this can run from a thread pool rather than a process pool for CPU-bound batches that need to share the same numpy arrays without an inter-process copy — a departure from the process-pool default recommended for pure-Python geometry work in async execution for spatial workloads, and worth revisiting once a PyO3 binding is in place.