Declarative spatial query layer for Polars
Project description
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
Installation
pip install pycanopy
Pre-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()
Why PyCanopy
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 spatial dataframe interface that abstracts away these complexities for users.
Edit [June 19 2026]: Apache Sedona released a cool Python DataFrame API. There are similarities between their API and this tool but some key differences are (1) this query planner interacts with Polars rather than just being an input source and (2) this uses a cost-model approch to dynamic indexing.
| PyCanopy | GeoPandas | DuckDB | SedonaDB | Spatial Polars | |
|---|---|---|---|---|---|
| Polars-native API | ✓ | ✗ | ✗ | ✗ | ✓ |
| Spatial query planner (reorder, fuse, pushdown) | ✓ | ✗ | ✓ | ✓ | ✗ |
| Index vs scan decided by cost model | ✓ | ✗ | ✗ | ✗ | ✗ |
| Dynamic index selection | ✓ | ✗ | ✗ | ✗ | ✗ |
Example Operations
Inspecting the query plan
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.
kNN join
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.
Proximity join with aggregation
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.
Polygon intersects self-join
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.
Benchmarks
Apache SpatialBench
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
|
How It Works
The engine has dedicated components for query planning / execution and ultimately returns a Polars DataFrame.
Query flow
flowchart LR
A[User chain] --> B[SpatialOptimizer] --> C[SpatialExecutor] --> F[pl.DataFrame]
Logical planning
- 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.
Cost model
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 ($Q$ queries, $N$ items):
$$ \text{winner} = \arg\min \begin{cases} \text{Cost}{\text{probe}}(\text{built index}) & \text{build already paid} \ \text{Cost}{\text{build}} + \text{Cost}{\text{probe}}(\text{best new index}) \ \text{Cost}{\text{probe}}(\text{brute force}) \end{cases} $$
Selectivity (fraction of the dataset expected to match):
$$ \text{sel} = \begin{cases} \text{hist}(\text{bbox}) / N & \text{range (32×32 density histogram)} \ k / N & \text{kNN} \ 1 / N & \text{contains} \end{cases} $$
Probe cost ($Q$ warm queries against a built index):
$$ \text{Cost}{\text{probe}} = Q \times \begin{cases} N \cdot c{\text{scan}} & \text{brute force} \ (\log_2 N + \text{sel} \cdot N) \cdot c_{\text{tree}} & \text{KD-tree or R-tree} \ \text{sel} \cdot N \cdot c_{\text{grid}} & \text{grid} \end{cases} $$
Build cost (paid once):
$$ \text{Cost}{\text{build}} = \begin{cases} 0 & \text{brute force} \ N \cdot c{\text{build}} & \text{grid} \ N \log_2 N \cdot c_{\text{build}} & \text{KD-tree or R-tree} \end{cases} $$
The empirical constants ($c_{\text{scan}}$, $c_{\text{tree}}$, $c_{\text{grid}}$, $c_{\text{build}}$) are calibrated from benchmark runs in bench/ops.
Index selection
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.
Why Rust
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.
Acknowledgements
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
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distributions
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pycanopy-0.3.2.tar.gz.
File metadata
- Download URL: pycanopy-0.3.2.tar.gz
- Upload date:
- Size: 377.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0e99705c4058b1050e6078f155995c40eae235d1eacc1f01e7627ac53057b70f
|
|
| MD5 |
16a82a32ca879aac231b7f2e5f43883d
|
|
| BLAKE2b-256 |
531f0f595e776532083bd4436f375b5a5614ec61ac1830baf28e9b8275bed879
|
Provenance
The following attestation bundles were made for pycanopy-0.3.2.tar.gz:
Publisher:
release.yml on pranav-walimbe/PyCanopy
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pycanopy-0.3.2.tar.gz -
Subject digest:
0e99705c4058b1050e6078f155995c40eae235d1eacc1f01e7627ac53057b70f - Sigstore transparency entry: 2074350666
- Sigstore integration time:
-
Permalink:
pranav-walimbe/PyCanopy@47b27cb0326037e977fd68fad9d991de8a252991 -
Branch / Tag:
refs/tags/v0.3.2 - Owner: https://github.com/pranav-walimbe
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@47b27cb0326037e977fd68fad9d991de8a252991 -
Trigger Event:
push
-
Statement type:
File details
Details for the file pycanopy-0.3.2-cp310-abi3-win_amd64.whl.
File metadata
- Download URL: pycanopy-0.3.2-cp310-abi3-win_amd64.whl
- Upload date:
- Size: 726.7 kB
- Tags: CPython 3.10+, Windows x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
28b3f6e16f5badf3e8a36bebd1a4279c94a874fc98d0cddb3f72b28593987ed8
|
|
| MD5 |
894f36509446d76e9661ae27c140ad6c
|
|
| BLAKE2b-256 |
ecc3c6b301d0507e443ee76ed9bb9e69c1d69ef8bf0a57a9ec93e416203218b9
|
Provenance
The following attestation bundles were made for pycanopy-0.3.2-cp310-abi3-win_amd64.whl:
Publisher:
release.yml on pranav-walimbe/PyCanopy
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pycanopy-0.3.2-cp310-abi3-win_amd64.whl -
Subject digest:
28b3f6e16f5badf3e8a36bebd1a4279c94a874fc98d0cddb3f72b28593987ed8 - Sigstore transparency entry: 2074351116
- Sigstore integration time:
-
Permalink:
pranav-walimbe/PyCanopy@47b27cb0326037e977fd68fad9d991de8a252991 -
Branch / Tag:
refs/tags/v0.3.2 - Owner: https://github.com/pranav-walimbe
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@47b27cb0326037e977fd68fad9d991de8a252991 -
Trigger Event:
push
-
Statement type:
File details
Details for the file pycanopy-0.3.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.
File metadata
- Download URL: pycanopy-0.3.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- Upload date:
- Size: 855.8 kB
- Tags: CPython 3.10+, manylinux: glibc 2.17+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
591a8f8e328fb035492002d654757dc855740db953a6bf3de1ea1f5dc5dba7a6
|
|
| MD5 |
c73093563ea472327570aa3f0577b426
|
|
| BLAKE2b-256 |
5caf8e475b17d79011840234352637818e9cb32b467f0477b0a6f5d70f0300a9
|
Provenance
The following attestation bundles were made for pycanopy-0.3.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl:
Publisher:
release.yml on pranav-walimbe/PyCanopy
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pycanopy-0.3.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl -
Subject digest:
591a8f8e328fb035492002d654757dc855740db953a6bf3de1ea1f5dc5dba7a6 - Sigstore transparency entry: 2074352018
- Sigstore integration time:
-
Permalink:
pranav-walimbe/PyCanopy@47b27cb0326037e977fd68fad9d991de8a252991 -
Branch / Tag:
refs/tags/v0.3.2 - Owner: https://github.com/pranav-walimbe
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@47b27cb0326037e977fd68fad9d991de8a252991 -
Trigger Event:
push
-
Statement type:
File details
Details for the file pycanopy-0.3.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.
File metadata
- Download URL: pycanopy-0.3.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
- Upload date:
- Size: 807.7 kB
- Tags: CPython 3.10+, manylinux: glibc 2.17+ ARM64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a0ed7c7f55ddee5e555bc284fb29c0f73f176af72092dfd4c67fa016575c4af0
|
|
| MD5 |
e0adc09bc89511e15cb56e64f8c92d5a
|
|
| BLAKE2b-256 |
6012e7dd0edd2cb08fcdd0e2d5aabc14ef70d2742b4a414fe5873314d93d05f6
|
Provenance
The following attestation bundles were made for pycanopy-0.3.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl:
Publisher:
release.yml on pranav-walimbe/PyCanopy
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pycanopy-0.3.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl -
Subject digest:
a0ed7c7f55ddee5e555bc284fb29c0f73f176af72092dfd4c67fa016575c4af0 - Sigstore transparency entry: 2074353171
- Sigstore integration time:
-
Permalink:
pranav-walimbe/PyCanopy@47b27cb0326037e977fd68fad9d991de8a252991 -
Branch / Tag:
refs/tags/v0.3.2 - Owner: https://github.com/pranav-walimbe
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@47b27cb0326037e977fd68fad9d991de8a252991 -
Trigger Event:
push
-
Statement type:
File details
Details for the file pycanopy-0.3.2-cp310-abi3-macosx_11_0_arm64.whl.
File metadata
- Download URL: pycanopy-0.3.2-cp310-abi3-macosx_11_0_arm64.whl
- Upload date:
- Size: 759.1 kB
- Tags: CPython 3.10+, macOS 11.0+ ARM64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
73d70de366162e937c25a681a9fa75ac61c8c5806a6e24cdaf87b111b8fdfe85
|
|
| MD5 |
7b208149818b4c137329f2eb0f0083e3
|
|
| BLAKE2b-256 |
4678a4c0b26f76fcc8af58b27727349be1ad201c8d66731e3d9cb7e8cbc32f32
|
Provenance
The following attestation bundles were made for pycanopy-0.3.2-cp310-abi3-macosx_11_0_arm64.whl:
Publisher:
release.yml on pranav-walimbe/PyCanopy
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pycanopy-0.3.2-cp310-abi3-macosx_11_0_arm64.whl -
Subject digest:
73d70de366162e937c25a681a9fa75ac61c8c5806a6e24cdaf87b111b8fdfe85 - Sigstore transparency entry: 2074352644
- Sigstore integration time:
-
Permalink:
pranav-walimbe/PyCanopy@47b27cb0326037e977fd68fad9d991de8a252991 -
Branch / Tag:
refs/tags/v0.3.2 - Owner: https://github.com/pranav-walimbe
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@47b27cb0326037e977fd68fad9d991de8a252991 -
Trigger Event:
push
-
Statement type:
File details
Details for the file pycanopy-0.3.2-cp310-abi3-macosx_10_12_x86_64.whl.
File metadata
- Download URL: pycanopy-0.3.2-cp310-abi3-macosx_10_12_x86_64.whl
- Upload date:
- Size: 813.3 kB
- Tags: CPython 3.10+, macOS 10.12+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0f5f7131a12b999c1d4a3c61f7dc591d910ef738f0b4017bbf03c7c3cb5af2ee
|
|
| MD5 |
0339e605d31bd30e3d5b08d993c945c4
|
|
| BLAKE2b-256 |
a0d50f44c182926984da1f12d373ddd2d9fd8d58ca0defa68d087ad8fb470e0b
|
Provenance
The following attestation bundles were made for pycanopy-0.3.2-cp310-abi3-macosx_10_12_x86_64.whl:
Publisher:
release.yml on pranav-walimbe/PyCanopy
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pycanopy-0.3.2-cp310-abi3-macosx_10_12_x86_64.whl -
Subject digest:
0f5f7131a12b999c1d4a3c61f7dc591d910ef738f0b4017bbf03c7c3cb5af2ee - Sigstore transparency entry: 2074352394
- Sigstore integration time:
-
Permalink:
pranav-walimbe/PyCanopy@47b27cb0326037e977fd68fad9d991de8a252991 -
Branch / Tag:
refs/tags/v0.3.2 - Owner: https://github.com/pranav-walimbe
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@47b27cb0326037e977fd68fad9d991de8a252991 -
Trigger Event:
push
-
Statement type: