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.
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.
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 toallow_threadsmust not touch anyPy<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. PyReadonlyArray1borrows, 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_aarch64will 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. extension-modulefeature changes linking. Forgetting theextension-modulefeature onpyo3inCargo.tomlproduces 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.
numpyfloat64 arrays and Rustf64are bit-identical, so there is no marshalling precision loss here — unlike a binding that narrows tofloat/f32at 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.
Related
- Python/C FFI for hot-path geometry — the call-boundary cost model that motivates choosing PyO3 over
ctypes/cffi. - ctypes vs. cffi for hot-path geometry — the lighter-weight bindings to reach for before adopting a Rust toolchain.
- Zero-copy coordinate buffers across the FFI boundary — the buffer-protocol technique
PyReadonlyArray1is built on.