Skip to main content

PyCanopy

PyPI version Python versions CI License: MIT Docs Open In Colab

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

PyCanopy vs SedonaDB, DuckDB, and GeoPandas on Apache SpatialBench SF1

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

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

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)

PyCanopy vs SedonaDB, DuckDB, and GeoPandas on Apache SpatialBench SF1

Apache SpatialBench SF1 · lower is better · linear axis, bars past the cap truncated with their value · TIMEOUT / ERROR annotated

SF10 (~60M trips)

PyCanopy vs SedonaDB, DuckDB, and GeoPandas on Apache SpatialBench SF10

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

QueryPyCanopySedonaDBDuckDBGeoPandas
q11.390.660.9612.78
q23.748.079.9520.74
q31.230.801.1713.59
q47.448.419.8325.24
q51.715.101.8047.08
q65.518.599.3624.43
q72.151.661.82137.00
q81.041.101.0816.08
q90.230.2350.150.28
q108.6518.79207.8446.13
q119.9032.98TIMEOUT51.01
q1214.8614.55ERRORTIMEOUT

SF10

QueryPyCanopySedonaDBDuckDBGeoPandas
q18.523.044.58ERROR
q29.398.898.26ERROR
q36.884.095.17TIMEOUT
q417.347.528.51ERROR
q514.6050.8114.40ERROR
q611.079.1110.67ERROR
q722.7314.4414.03ERROR
q87.307.247.57TIMEOUT
q90.340.38942.980.49
q1027.2642.02ERRORERROR
q1137.2197.52ERRORERROR
q12175.31145.66ERRORTIMEOUT

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 get interleaved into a single Rust call.
  • 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_batches expression 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 TD
    A[Query arrives] --> B{N < 500\nor sel > 50%?}
    B -- yes --> BF[Brute force]
    B -- no --> C{kNN with\nk/N > 10%?}
    C -- yes --> BF
    C -- no --> D{Polygon\ndataset?}
    D -- yes --> RT[R-tree]
    D -- no --> E{Range query\nand uniform?}
    E -- yes --> GR[Grid]
    E -- no --> KD[KD-tree]

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

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pycanopy-0.3.4.tar.gz (386.1 kB view details)

Uploaded Source

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

pycanopy-0.3.4-cp310-abi3-win_amd64.whl (747.4 kB view details)

Uploaded CPython 3.10+Windows x86-64

pycanopy-0.3.4-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (880.9 kB view details)

Uploaded CPython 3.10+manylinux: glibc 2.17+ x86-64

pycanopy-0.3.4-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (832.7 kB view details)

Uploaded CPython 3.10+manylinux: glibc 2.17+ ARM64

pycanopy-0.3.4-cp310-abi3-macosx_11_0_arm64.whl (790.0 kB view details)

Uploaded CPython 3.10+macOS 11.0+ ARM64

pycanopy-0.3.4-cp310-abi3-macosx_10_12_x86_64.whl (840.0 kB view details)

Uploaded CPython 3.10+macOS 10.12+ x86-64

File details

Details for the file pycanopy-0.3.4.tar.gz.

File metadata

  • Download URL: pycanopy-0.3.4.tar.gz
  • Upload date:
  • Size: 386.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for pycanopy-0.3.4.tar.gz
Algorithm Hash digest
SHA256 3c7ee77acf848f27ecd9f89e62a2fcbd54b83c05983303461185c960c12b602a
MD5 7468e4fc627509eca68c7b4ff350937d
BLAKE2b-256 639bc2fbbf751d2d8c6ed3c228b144d05915160517f20ebea004e2450e80cedd

See more details on using hashes here.

Provenance

The following attestation bundles were made for pycanopy-0.3.4.tar.gz:

Publisher: release.yml on pranav-walimbe/PyCanopy

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pycanopy-0.3.4-cp310-abi3-win_amd64.whl.

File metadata

  • Download URL: pycanopy-0.3.4-cp310-abi3-win_amd64.whl
  • Upload date:
  • Size: 747.4 kB
  • Tags: CPython 3.10+, Windows x86-64
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for pycanopy-0.3.4-cp310-abi3-win_amd64.whl
Algorithm Hash digest
SHA256 8d3f8e7b4b95d6c46e48bd6ddc7336aa0f47c54afa7b427b8512ff8b14016d34
MD5 bcbdf24a9a4e378d6a319f540a4ff999
BLAKE2b-256 0041cfaad95b90516e337e4638631ff52d727afccdd0522c9950e89f07f1307b

See more details on using hashes here.

Provenance

The following attestation bundles were made for pycanopy-0.3.4-cp310-abi3-win_amd64.whl:

Publisher: release.yml on pranav-walimbe/PyCanopy

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pycanopy-0.3.4-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for pycanopy-0.3.4-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 a7ae6adbd72b01981a856b5b3ddfa15d591523e6b2383895ab462cc1576960ce
MD5 3f11d851da2b2480edaf389d61ceebbc
BLAKE2b-256 48bd56aa4b6f09cc4d17d634b35df97ab18b2cb4370e62288aea99dff6ad194d

See more details on using hashes here.

Provenance

The following attestation bundles were made for pycanopy-0.3.4-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl:

Publisher: release.yml on pranav-walimbe/PyCanopy

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pycanopy-0.3.4-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.

File metadata

File hashes

Hashes for pycanopy-0.3.4-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Algorithm Hash digest
SHA256 17fac5cd7895c32f6d66d614a338994ef22212f97f75371d3de9f6c9ffa0cef1
MD5 dae4985cd6de20473950694604b2c48a
BLAKE2b-256 30a92f3044ba9b9b677860435a8c6978ecc1ca76276091ce11fad509e2312ff2

See more details on using hashes here.

Provenance

The following attestation bundles were made for pycanopy-0.3.4-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl:

Publisher: release.yml on pranav-walimbe/PyCanopy

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pycanopy-0.3.4-cp310-abi3-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for pycanopy-0.3.4-cp310-abi3-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 9de1adde992335106e4f13f369d81037d74a434cc36e2d1b87ec957bfdfb73f4
MD5 2ac5452cca2ad7ccf7019c1f5d49cbde
BLAKE2b-256 cacd44941a33764489512367e0efe3bfe406c78d0602c9d20a0cd2713f83bca8

See more details on using hashes here.

Provenance

The following attestation bundles were made for pycanopy-0.3.4-cp310-abi3-macosx_11_0_arm64.whl:

Publisher: release.yml on pranav-walimbe/PyCanopy

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pycanopy-0.3.4-cp310-abi3-macosx_10_12_x86_64.whl.

File metadata

File hashes

Hashes for pycanopy-0.3.4-cp310-abi3-macosx_10_12_x86_64.whl
Algorithm Hash digest
SHA256 b97657407d794298375ef7d33a31b884f06d65c1159f640a598044170f029156
MD5 95f4bf8cdacced685d61da36d4a2ffe9
BLAKE2b-256 9ef136a27b009e55c0a38440d788bbe6f63acae095b85c5b1486644267f2b852

See more details on using hashes here.

Provenance

The following attestation bundles were made for pycanopy-0.3.4-cp310-abi3-macosx_10_12_x86_64.whl:

Publisher: release.yml on pranav-walimbe/PyCanopy

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.4.1

6 files

0.4.0

6 files

This release

0.3.4 This release

6 files

0.3.3

6 files

0.3.2

6 files

0.3.1

6 files

0.3.0

6 files

0.2.2

6 files

0.2.1

6 files

0.2.0

6 files

0.1.2

6 files

0.1.0

6 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page