Blog · Systems ·
72 wheels: shipping a Rust extension as a Python package when your dependency has no ABI
rtlf links Polars' internal Rust crates, so the compiled .so embeds a hash of Polars' own plan types: one wheel per (OS, arch, Python, polars version), and a script that rewrites its own manifests to get there.
- interactive
- rust
- python
- pyo3
- maturin
- ci
- packaging
The previous post was about an algorithm: cache a Polars query plan once, replay it
against a stream of DataFrames, skip the optimizer. That part was fun to build and is
maybe 400 lines of Rust. Getting it onto PyPI as a wheel anyone could pip install was
harder, stranger, and took most of the actual engineering time, and there’s almost
nothing written about this particular problem, because it only shows up when your Rust
crate links another project’s internal Rust crates rather than calling a stable C
ABI. This is that story.
The ABI problem nobody warns you about
rtlf doesn’t just depend on Polars the Python package. It depends on polars-lazy,
polars-plan, polars-stream and nine other polars-* Rust crates directly, because
the whole trick (patching the optimized IR arena, hooking the physical-plan executor)
only works from inside Polars’ own query-planning code. There’s no public API for any
of it.
That means the compiled .so isn’t just linked against “Polars” in the abstract. It
embeds DSL_SCHEMA_HASH, a SHA256 of the exact Rust type definitions that make up
Polars’ plan IR, baked in at compile time. The installed Python polars wheel has to
produce the same hash from its own compiled extension, or the two sides disagree
about what a plan node even looks like: not a type error, not a nice Python exception,
just a mismatch at import time. There’s no version range that’s “close enough.”
The first complication is that crates.io’s polars versions and PyPI’s polars
versions aren’t the same artifact. The published Rust crates lag, diverge, and don’t
correspond one-to-one with the wheels PyPI ships. So Cargo.toml patches all 21
polars-* crates (and pyo3-polars, which is built from the same monorepo) straight to
a git tag of the polars repository itself:
[patch.crates-io]
polars = { git = "https://github.com/pola-rs/polars", tag = "py-1.38.1" }
polars-arrow = { git = "https://github.com/pola-rs/polars", tag = "py-1.38.1" }
polars-buffer = { git = "https://github.com/pola-rs/polars", tag = "py-1.38.1" }
polars-compute = { git = "https://github.com/pola-rs/polars", tag = "py-1.38.1" }
# … 17 more lines, one per polars-* crate, all pinned to the same tag
pyo3-polars = { git = "https://github.com/pola-rs/polars", tag = "py-1.38.1" }
py-1.38.1 is the tag Polars cuts for its own Python release: building against anything
else, including a perfectly valid crates.io release, produces a hash mismatch. The other
half of why this build is expensive sits in the same manifest, in its [profile.release]
block:
[profile.release]
codegen-units = 1
lto = "fat"
Full link-time optimisation across a dependency tree that includes most of Polars’ execution engine. That’s not a knob turned up for fun: it’s what makes a Python extension of this size fast enough to be worth shipping, and it’s also exactly what runs out of memory under emulation later in this post.
One wheel, then, is only valid for one exact combination of operating system, architecture, Python minor version, and polars version. Cross any of those and either the wheel doesn’t load (wrong platform tag) or it loads and produces the wrong answer silently (hash happens to still match on an unrelated axis, plan types don’t). The matrix at the bottom of this post is what “cross any of those” costs in practice.
Project layout with maturin
The Cargo side compiles to a cdylib, not a normal Rust library:
[lib]
name = "_rtlf"
crate-type = ["cdylib"]
pyproject.toml tells maturin where the Python-facing half of the
package lives and what to call the compiled module once it’s built:
[tool.maturin]
# The Rust extension module is placed inside the Python package directory.
# Python source lives under python/ so maturin knows where to find it.
python-source = "python"
module-name = "rtlf._rtlf"
features = ["pyo3/extension-module"]
So the compiled extension lands as rtlf._rtlf (an
underscore-prefixed private module), and the public package rtlf is three lines of
plain Python re-exporting the two classes the extension provides:
from rtlf._rtlf import PyCompiledRealtimeLazyFrame, PyRealtimeLazyFrame
__all__ = ["PyRealtimeLazyFrame", "PyCompiledRealtimeLazyFrame"]
It’s a small thing, but it’s the difference between users writing
from rtlf._rtlf import PyRealtimeLazyFrame (reaching into a module named with an
underscore, a module whose name is an implementation detail of maturin’s layout), and
writing from rtlf import PyRealtimeLazyFrame. The #[pymodule] on the Rust side
matches: _rtlf registers exactly those two #[pyclass] types and nothing else.
pyo3 in practice: four small lessons
The Rust-Python boundary module in rtlf is thin, just over ninety lines, but it’s
dense with pyo3 idioms worth having seen once.
Schema from a Python dict. One entry point takes a schema as a plain
dict[str, pl.DataType] from Python and calls a small helper to turn it into a Polars
Schema:
fn extract_schema(ob: &Bound<'_, PyAny>) -> PyResult<Schema> {
let dict = ob.cast::<PyDict>()?;
dict.iter()
.map(|(k, v)| {
let name = k.extract::<PyBackedStr>()?;
let dtype = v.extract::<PyDataType>()?.0;
Ok(Field::new((&*name).into(), dtype))
})
.collect::<PyResult<Schema>>()
}
PyBackedStr borrows the Python string’s underlying buffer
instead of copying it into a Rust String; PyDataType is pyo3-polars’s wrapper that
knows how to read a polars.DataType object back into the Rust DataType enum. The
collect::<PyResult<Schema>>() at the end is the usual trick of collecting an iterator
of Results into a Result of a collection: the whole dict is either extracted
cleanly or the first bad entry short-circuits the loop with a PyErr.
Zero-copy frames. Every PyDataFrame and PyLazyFrame crossing the boundary is a
pyo3-polars newtype around the real DataFrame / LazyFrame. unwrap_inputs
just unwraps the HashMap<String, PyDataFrame> Python handed
over into the HashMap<String, DataFrame> the Rust side wants. No serialization, no
Arrow IPC round-trip: it’s the same underlying Arrow buffers, wearing a different
Rust type on either side of the call.
Releasing the GIL. Both collect methods wrap the actual work in py.detach:
fn collect(&self, py: Python<'_>, inputs: HashMap<String, PyDataFrame>) -> PyResult<PyDataFrame> {
let rust_inputs = unwrap_inputs(inputs);
py.detach(|| {
self.inner
.collect(rust_inputs)
.map(PyDataFrame)
.map_err(|e| pyo3::PyErr::from(PyRtlfErr::from(e)))
})
}
Without py.detach, the Global Interpreter Lock stays held for the entire Rust-side execution,
which, for CompiledRealtimeLazyFrame, is a multithreaded polars-stream query running
underneath. Every other Python thread in the process would simply wait. py.detach
releases the GIL for the closure’s duration and re-acquires it before returning, so a
collect() call from one thread doesn’t stall the interpreter for everyone else.
The orphan rule. This is the one worth pausing on, because it’s a Rust language
constraint dressed up as a design choice. rtlf wants PolarsErrors to become Python
exceptions automatically via ?, which means it wants:
impl From<PolarsError> for PyErr
Rust won’t allow it. The orphan rule says an impl Trait for Type is only legal in the
crate that owns Trait or the crate that owns Type, and rtlf owns neither
From/PyErr (that’s pyo3’s) nor PolarsError (that’s Polars’). The fix is the
textbook one: wrap the foreign type in a newtype you do own, and implement the
conversions on that instead. The whole file is 23 lines:
use polars_core::error::PolarsError;
use pyo3::PyErr;
/// Newtype so we can impl From<PolarsError> for PyErr (orphan rule).
pub struct PyRtlfErr(pub PolarsError);
impl From<PolarsError> for PyRtlfErr {
fn from(e: PolarsError) -> Self {
Self(e)
}
}
impl From<PyRtlfErr> for PyErr {
fn from(e: PyRtlfErr) -> PyErr {
use PolarsError::*;
match e.0 {
ColumnNotFound(msg) => pyo3::exceptions::PyKeyError::new_err(msg.to_string()),
ComputeError(msg) => pyo3::exceptions::PyRuntimeError::new_err(msg.to_string()),
IO { error, .. } => pyo3::exceptions::PyIOError::new_err(error.to_string()),
other => pyo3::exceptions::PyRuntimeError::new_err(other.to_string()),
}
}
}
Two small From impls, and every fallible call site in the boundary module can write
.map_err(PyRtlfErr::from)? or the two-step
.map_err(|e| pyo3::PyErr::from(PyRtlfErr::from(e)))? seen above, and get a real
KeyError for a missing column or a real IOError for a filesystem problem, rather
than one undifferentiated RuntimeError for everything. It’s a small file, but it’s a
clean answer to a question every pyo3 project that touches a third-party error type
eventually has to ask.
A build script that edits its own manifests
Here’s the part that’s unusual even by Rust-packaging standards. There is no single
Cargo.toml for rtlf: there’s a Cargo.toml that gets rewritten before every
build, by a 129-line Python script, from a table that maps each supported polars release
to everything else that has to change alongside it:
# Each entry maps a polars Python release to:
# rtlf - the package version published to PyPI
# crate - the polars-* crate semver (changes with minor polars releases)
# nightly - the Rust nightly that matches the DSL_SCHEMA_HASH for this polars build
# pyo3 - pyo3 crate version used by this polars build
# pyo3_polars - pyo3-polars crate version used by this polars build
#
# Versioning scheme: rtlf 0.POLARS_MINOR.POLARS_PATCH
# e.g. polars 1.38.1 → rtlf 0.38.1
Eleven such entries exist as of this post; the CI matrix below builds six of them.
Running the configure script for a target polars version, 1.41.1 say, looks up that
row and patches three files in place: Cargo.toml’s crate versions and git tag,
pyproject.toml’s package version and polars== pin, and the toolchain-pin file’s
nightly channel: three sources of truth, generated from one table, so nobody hand-edits
a version number in the wrong file and gets a wheel that silently expects a nightly that
isn’t installed.
The regex that patches Cargo.toml’s dependency versions is anchored to the start of
the line on purpose:
# Update crate version numbers in [dependencies]
# Anchored to line start so "pyo3-polars" (contains "polars") is not matched.
# Matches: polars-foo = { version = "0.52", ... }
text = re.sub(
r'(?m)^(polars[-\w]* = \{ version = )"[^"]+"',
lambda m: f'{m.group(1)}"{crate_ver}"',
text,
)
pyo3-polars legitimately contains the substring polars, so an unanchored pattern
would match it and overwrite its version with the polars
crate’s version number instead of its own: wrong, and wrong in a way that would compile
fine and fail at runtime. Anchoring to ^ (with re.MULTILINE) means the match has to
start a line, and pyo3-polars = {...} starts with pyo3, not polars, so it’s
skipped by construction. pyo3 and pyo3-polars each get their own separate,
correctly-targeted substitution three lines later.
The other detail worth keeping is the feature rename table:
# Feature renames keyed by the polars version they were introduced in.
# Each entry: (since_polars, old_name, new_name)
# configure.py normalises to old_name for versions before the cutoff and
# new_name for versions at or after, so it is safe to run in either direction.
_FEATURE_RENAMES: list[tuple[str, str, str]] = [
("1.41.0", "new_streaming", "streaming"),
]
Polars renamed its new_streaming Cargo feature to streaming at 1.41.0. rtlf’s
Cargo.toml requests it under one name or the other
depending on which polars version this run targets, applied idempotently regardless of
which name is currently in the file: running the configure script twice in a row, or
running it for an older version after a newer one, converges to the same text either
time.
The 72-job matrix, and two battle scars
The workflow’s strategy.matrix crosses four platforms, three Python versions and six
polars versions: 4 × 3 × 6 = 72 build-and-test jobs, each running the configure script
for its polars version before maturin-action ever sees the manifests. Two of those
platform legs earned real comments in the workflow file, and
they’re worth quoting exactly as written rather than paraphrased:
env:
# manylinux2014 cross-toolchain's old gcc doesn't predefine __aarch64__
# when compiling .S files, so ring's asm_base.h fails its __ARM_ARCH check.
# Defining it explicitly via CFLAGS is the standard workaround.
CFLAGS_aarch64_unknown_linux_gnu: ${{ matrix.platform.qemu && '-D__ARM_ARCH=8' || '' }}
# fat LTO + QEMU emulation OOMs the 7GB runner; thin LTO uses far less memory.
CARGO_PROFILE_RELEASE_LTO: ${{ matrix.platform.qemu && 'thin' || 'fat' }}
The first is a cross-compilation quirk one layer removed from rtlf entirely: ring
(a transitive dependency, pulled in for TLS) checks
__ARM_ARCH while assembling its hand-written .S files, and the manylinux2014
cross-toolchain’s older gcc doesn’t predefine __aarch64__ when it assembles them,
so the check fails unless the value is forced explicitly via CFLAGS. The second is the
[profile.release] block from earlier catching up with reality: lto = "fat" plus
codegen-units = 1 is expensive to link even natively, and under QEMU’s aarch64
emulation on a 7 GB GitHub-hosted runner, that link step runs out of memory outright.
The fix isn’t to weaken the release profile everywhere (the fast, native legs keep fat
LTO); it’s to drop to thin LTO on exactly the one leg where fat LTO can’t fit in the
runner’s memory.
The job-level settings are just as deliberate:
jobs:
build-and-test:
runs-on: ${{ matrix.platform.os }}
continue-on-error: true
strategy:
fail-fast: false
fail-fast: false means one failing cell doesn’t cancel the other 71: useful on its
own with a matrix this wide, since a single
transient runner problem shouldn’t blank out the whole build. continue-on-error: true
goes further: the job itself is allowed to fail without failing the workflow. Some
platform × Python × polars combinations are expected not to work, and the point of the
matrix isn’t to demand all 72 pass: it’s to find out which ones do. The artifact-upload
step has no if: always() on it, so it’s only reached after the test step succeeds:
# Only reached if tests pass
- uses: actions/upload-artifact@v4
That one-line comment is doing real work: it means the publish job, which downloads
every uploaded artifact and pushes it to PyPI, can
only ever see wheels that were built and imported and passed the test suite on their
actual target platform. A wheel that fails to build never gets an artifact. A wheel
that builds but fails its test suite never gets an artifact either. continue-on-error
lets that failure show up as a red job in the Actions UI without blocking the other 71,
but it doesn’t smuggle a broken wheel past the gate.
Publishing itself uses OIDC trusted publishing rather than a stored token:
publish:
needs: build-and-test
environment: pypi
permissions:
id-token: write
steps:
- uses: pypa/gh-action-pypi-publish@release/v1
with:
skip-existing: true
No PYPI_API_TOKEN secret to rotate or leak: GitHub Actions mints a short-lived OIDC
token scoped to the pypi environment,
and PyPI trusts it because the project registered this exact repository and workflow as
a publisher. skip-existing: true makes re-runs safe: if 68 of the 72 legs already
published successfully and four flaked, re-running the workflow only uploads the four
that are missing.
What it costs to depend on someone’s internals
None of this (the patched crates, the version table, the per-leg LTO override, the
nightly pin) would exist if rtlf called a stable, public Polars API. It exists
because the fastest path to “skip the optimizer” ran directly through Polars’ own
internal IR and executor types, which was never designed to be depended on and comes
with no compatibility promise attached. I say as much myself, right in the first line
of rtlf’s README: this is an experimental workaround for a specific upstream issue,
tested on one polars version, and the whole CI apparatus in this post is the tax for
supporting more than that one version without hand-testing all eleven.
That tax is worth paying when the thing you’re building genuinely can’t be built any
other way, when the sanctioned API is measurably slower (as the previous post’s
ExecutionState-cache detour found out), or doesn’t exist at all, and when you say so, loudly, in the first line anyone will
actually read, about exactly how narrow the ground you’re standing on is. It stops being worth it the moment the
internals move and
nobody’s watching: a library like this needs the matrix below re-run on every upstream
release, forever, or it quietly stops being safe to use. Seventy-two wheels is what
“supporting six Polars releases across four platforms” costs when your one Rust crate
has to match a hash baked into someone else’s compiled binary. It’s a lot of CI for
480 lines of Rust. Every line of that CI is there because the alternative was
either “don’t ship this” or “ship something that only works on my laptop.”
Build Matrix Explorer
The configure script is deterministic: give it a polars version and it tells you the
rtlf version, the crate semver, the required nightly, and the pyo3 pins. Cross that with
the platform axis from the workflow’s env: block and you get, for any one of the 72
cells below, the exact Cargo.toml, pyproject.toml and toolchain-pin fragments that
leg would build with, and, on the QEMU leg, the two comments above, attached to the
cell they actually apply to.
With JavaScript enabled this becomes a clickable 4×3×6 grid (platform by Python by
polars version) where every cell shows the resolved rtlf version, the Rust nightly,
the pyo3 pins, the git tag, and the exact Cargo.toml /
pyproject.toml / toolchain-pin fragments the configure script would
generate for that build, with a copy button on each. Filters narrow the grid by
platform, Python or polars version; the emulated
aarch64 row is marked and explains, in the workflow’s own words, why its build
settings differ from the other three.