Skip to content

Arrow interchange for morton_index

mortie exposes its packed 64-bit morton_index words to the Arrow ecosystem through two surfaces. Both carry the same uint64 storage and the same mortie.morton_index extension type, and both map the all-zero empty sentinel to an Arrow null and back. Neither is a runtime dependency — numpy stays the only hard dep.

surface module needs use for
pyarrow ExtensionType skin mortie.arrow.from_morton_index / to_morton_index pyarrow parquet / IPC, table.to_pandas(), off-worker analysis
library-agnostic C Data Interface mortie.arrow.export_c_array / import_c_array (+ MortonIndexArray.__arrow_c_array__ / from_arrow) nothing beyond numpy zero-copy handoff to arro3-core, polars, pyarrow — including envs with no pyarrow

The second surface (issue #93) is what lets a morton_index column travel through arro3-core — the pyarrow-free Arrow carrier used on constrained workers (e.g. an AWS Lambda layer without pyarrow). The raw Arrow C structs are built in Rust (via the arrow crate), so nothing on the critical path imports pyarrow.

Producing a column (any Arrow lib)

export_c_array returns the (schema_capsule, array_capsule) pair of the Arrow PyCapsule C Data Interface from raw uint64 words (or a MortonIndexArray). Wrap it in a tiny object exposing __arrow_c_array__ and hand it to any Arrow constructor:

import numpy as np
from mortie import arrow as marrow

words = ...  # uint64 numpy array of packed morton_index words (0 == null)

class MortonColumn:
    def __arrow_c_array__(self, requested_schema=None):
        return marrow.export_c_array(words)
    def __arrow_c_schema__(self):
        return marrow.export_c_schema()

# arro3-core (no pyarrow needed):
from arro3.core import Array
a3 = Array.from_arrow(MortonColumn())
# a3.field.metadata_str["ARROW:extension:name"] == "mortie.morton_index"

# pyarrow, if installed, resolves the registered extension type:
import pyarrow as pa
pa_arr = pa.array(MortonColumn())          # type: extension<mortie.morton_index>

MortonIndexArray (the pandas extension array) implements __arrow_c_array__ directly, so it can be passed straight to Array.from_arrow(...) / pa.array(...).

Consuming a column back to words

import_c_array accepts either an object exposing __arrow_c_array__ (an arro3-core / pyarrow / polars array) or a raw (schema_capsule, array_capsule) tuple, and returns a uint64 numpy array (Arrow nulls come back as the empty sentinel 0):

words = marrow.import_c_array(a3)                    # from arro3-core
mia = MortonIndexArray.from_arrow(a3)                # straight to the pandas array

Extension metadata survives arro3-core

The exported schema carries the extension type as field metadata (ARROW:extension:name = mortie.morton_index). arro3-core 0.8.1 round-trips this metadata through the C-Data boundary (verified in mortie/tests/test_arrow_cdata.py::TestArro3Interop), so a column stays typed end-to-end with no fallback to bare uint64. If a future carrier drops the metadata, the words still transfer (storage is uint64); re-attach the type at the edge with MortonIndexArray.from_arrow.

Null / sentinel semantics

The missing value is the all-zero empty word (MortonIndexArray.isna()), the kernel's null sentinel. On export it becomes an Arrow null via a real validity bitmap; on import a null becomes the sentinel again — byte-for-byte through any Arrow lib, not just via pyarrow's fill_null.

Running the arro3-core tests locally

arro3-core is not in the CI test extra, so the arro3 leg of test_arrow_cdata.py skips in CI (the pyarrow leg runs there). To exercise it locally, install the pinned carrier and run the suite:

pip install "mortie[arro3]"    # or: pip install arro3-core==0.8.1
pytest mortie/tests/test_arrow_cdata.py