Theme

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" }

(Cargo.toml:72-93.) 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 [profile.release] block two lines below is the other half of why this build is expensive: codegen-units = 1 and lto = "fat" (Cargo.toml:95-98), 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"]

(Cargo.toml:6-8.) 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"]

(pyproject.toml:15-20.) 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"]

(python/rtlf/__init__.py.) 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 (src/lib.rs:9-14).

pyo3 in practice: four small lessons

The Rust-Python boundary in rtlf is thin (src/python/mod.rs is 91 lines), but it’s dense with pyo3 idioms worth having seen once.

Schema from a Python dict. read_placeholder takes a schema as a plain dict[str, pl.DataType] from Python and has 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>>()
}

(src/python/mod.rs:15-24.) 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 (src/python/mod.rs:26-28) 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)))
    })
}

(src/python/mod.rs:61-69, and identically at 82-90 for the compiled path.) 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()),
        }
    }
}

(src/error.rs, in full.) Two small From impls, and every fallible call site in src/python/mod.rs 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.

ci/configure.py: a build system 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

(ci/versions.toml:1-9.) Eleven such entries exist as of this post; the CI matrix below builds six of them. ci/configure.py 1.41.1 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 rust-toolchain.toml’s 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,
)

(ci/configure.py:51-58.) 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"),
]

(ci/configure.py:36-42.) 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 configure.py 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 python3 ci/configure.py 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' }}

(.github/workflows/ci.yml:56-62.) 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

(.github/workflows/ci.yml:10-15.) 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 actions/upload-artifact 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

(.github/workflows/ci.yml:80-81.) 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 pytest tests/ 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

(.github/workflows/ci.yml:86-102, abridged.) 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. The README says as much in its first line: 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’re honest, loudly and in the first line of the README, 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

ci/configure.py 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 rust-toolchain.toml fragments that leg would build with, and, on the QEMU leg, the two comments above, attached to the cell they actually apply to.

InteractiveBuild Matrix Explorer

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 / rust-toolchain.toml fragments ci/configure.py 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.