A spatial query layer for Polars. Rust core, Python API.
Note
Highly competitive on Apache SpatialBench (single node spatial query benchmark): fastest on 11/24 testcases, within 5% of winning on 14/24 testcases
Apache SpatialBench SF1 · lower is better · bars past the cap truncated with their value · TIMEOUT / ERROR annotated
pip install pycanopyPre-built wheels for Linux, macOS, and Windows. No Rust toolchain required.
import polars as pl
from pycanopy import SpatialFrame
sf = SpatialFrame(pl.read_parquet("cities.parquet"), x_col="lon", y_col="lat")
result = sf.lazy().filter(pl.col("population") > 100_000).range_query(-10.0, 35.0, 40.0, 70.0).collect()During my undergrad research, I saw firsthand how spatial dataframe tooling could use performance improvements.
The driving motivator behind creating this library was to provide the optimizations of relational DBs (query planning, indexing, etc) in a fast, Polars-like interface meant for in-memory spatial work.
Edit [June 19 2026]: Apache SedonaDB released a cool Python DataFrame API. There are similarities between their API and this tool but some key differences are that this library uses (1) a Polars-native query engine and (2) a cost model that decides whether and how to index.
| PyCanopy | GeoPandas | DuckDB | SedonaDB | Spatial Polars | |
|---|---|---|---|---|---|
| Polars-native API | ✓ | ✗ | ✗ | ✗ | ✓ |
| Spatial query planner (reorder, pushdown, etc) | ✓ | ✗ | ✓ | ✓ | ✗ |
| Index vs scan decided by cost model | ✓ | ✗ | ✗ | ✗ | ✗ |
| Dynamic index selection | ✓ | ✗ | ✗ | ✗ | ✗ |
lf = (
sf.lazy()
.range_query(min_x=-10.0, min_y=35.0, max_x=40.0, max_y=70.0)
.filter(pl.col("population") > 100_000)
)
print(lf.explain())
# RANGE_QUERY [(-10, 35) → (40, 70)]
# FROM
# FILTER [(col("population")) > (dyn int: 100000)]
# FROM
# DF [N=100,000; path: EXPR]The optimizer moved the scalar filter below the range query. It runs first on all rows, then the spatial index is probed on the smaller survivor set.
query_df = pl.DataFrame({"qx": [2.35, 13.4], "qy": [48.85, 52.5]})
result = sf.lazy().knn_join(query_df, x_col="qx", y_col="qy", k=3).collect()For each row in query_df, returns the 3 nearest rows in the SpatialFrame. Large probes are streamed in morsels automatically.
import pycanopy as pc
stats = (
sf.lazy()
.within_distance_join(landmarks, x_col="lon", y_col="lat", distance=0.5)
.group_by(["landmark"])
.agg(count=pc.agg.count(), avg_fare=pc.agg.mean("fare"))
)The full pair frame is never materialised. Each probe morsel folds into per-group partials and combines at the end.
from shapely.geometry import box
polygons = [box(i, 0, i + 1.5, 1.0) for i in range(10_000)]
sf = SpatialFrame.from_polygons(pl.DataFrame({"id": range(10_000), "geom": polygons}), geometry_col="geom")
overlaps = sf.intersects_pairs(key_col="id")Returns all intersecting polygon pairs with overlap area and IoU. key_col replaces positional indices with values from that column.
Note
For the full operation catalogue, index modes, streaming joins, and API reference see the docs site.
Run on a single m7i.2xlarge (8 vCPU, 32 GB), the same hardware used by Apache SpatialBench. PyCanopy is measured live with index_mode="auto". Results were produced using the benchmark harness in bench/spatial_bench.
PyCanopy wins a total of 11/24 testcases and lands within 5% of winning 14/24 testcases (there is some variance among benchmark runs).
SF1 (~6M trips)
Apache SpatialBench SF1 · lower is better · linear axis, bars past the cap truncated with their value · TIMEOUT / ERROR annotated
SF10 (~60M trips)
Apache SpatialBench SF10 · lower is better · linear axis, bars past the cap truncated with their value · TIMEOUT / ERROR annotated
All times in seconds. Bold = fastest on that query. SedonaDB, DuckDB, and GeoPandas baselines from published SpatialBench results.
|
SF1
|
SF10
|
The engine has dedicated components for query planning / execution and ultimately returns a Polars DataFrame.
flowchart LR
A[User chain] --> B[SpatialOptimizer] --> C[SpatialExecutor] --> F[pl.DataFrame]
- Predicate pushdown: scalar filters run first, reducing rows before any spatial work.
- Fusion: consecutive range/contains predicates merge into a single operation.
- Join side: indexes on the side that makes the join most efficient.
- Projection pushdown: a terminal
.select()narrows both join sides before the gather. - IO path: low-selectivity queries return results as a direct slice, bypassing the Polars expression pipeline.
- EXPR path: runs the spatial engine as a Polars
map_batchesexpression over the query set.
index_mode determines how we use the cost model:
| Mode | Behaviour |
|---|---|
auto (default) |
build index when cost model allows it |
eager |
always build the selected index type, skip the cost check |
none |
always scan |
When index_mode="auto", the planner picks the minimum-cost option (
Selectivity (fraction of the dataset expected to match):
Probe cost (
Build cost (paid once):
The empirical constants (bench/ops.
select_index is a rule-based pre-filter that picks a candidate index type:
flowchart LR
A[Query arrives] --> B{N < 500\nor sel > 50%?}
B -- yes --> BF[Brute force]
B -- no --> C{kNN and\nk/N > 10%?}
C -- yes --> BF
C -- no --> D{Polygon\ndataset?}
D -- yes --> RT[R-tree]
D -- no --> E{Query type}
E -- kNN / contains --> KD[KD-tree]
E -- range --> F{Uniform?}
F -- yes --> GR[Grid]
F -- no --> KD
All index types share the same coordinate arrays with no duplication.
The hot paths need packed immutable index structures, zero-copy array slices at the Python boundary, and loop-level parallelism. C++ would require a separate FFI layer and would lose the native Polars plugin integration that PyO3/Maturin provides for free.
Some works that inspired this project:
- Polars: a columnar DataFrame engine that PyCanopy builds on
- geo-index: provides packed, immutable, zero-copy KD-tree and R-tree structures used
- Spatial Polars: an earlier effort to bring spatial functionality to Polars
- Apache Sedona: state-of-the-art spatial SQL engine + benchmark for evals
MIT


