Performance

How rgSpatial compares.

We benchmark rgSpatial against the tools most teams use today — GeoPandas, sf, DuckDB Spatial, PostGIS, Apache Sedona, and GDAL for file I/O. These are our own measurements: one machine, the same data and settings on each side, parallelism enabled for every engine, and every result checked for correctness. Versions and dates are listed with each result.

Summary

Five engines. One protocol. Median speedup.

The same spatial operations — joins, buffers, overlays, dissolve, predicates, measures — on the same data, on the same machine, with parallel execution enabled and verified for every engine. Click any bar for the full per-operation breakdown.

Identical work, matched N: each op is sized to the largest N the competitor completes within a fixed budget, and rg is timed on the same slice. Every result is output-verified before it is timed. Measured 2026-07-04/05 on a 16-core desktop (AMD Ryzen 9 7950X3D, 64 GB). Versions: GeoPandas 1.1.3 / Shapely 2.1.2, sf 1.0.24, PostGIS 16/3.4, DuckDB 1.5.4 + spatial, Apache Sedona single-node Spark. GeoPandas and sf are single-threaded by design and measured as shipped; PostGIS runs 32 parallel workers (EXPLAIN-verified), DuckDB threads=32, Sedona local[*] repartitioned across all cores.

Engine 1 of 5 · Python

vs GeoPandas — median 40.2×, 18 of 18 won.

Python’s standard geospatial stack (GeoPandas 1.1.3 / Shapely 2.1.2), measured as shipped on loaded, native objects — no I/O in the timed region on either side.

make_valid100,000 features · rg 27 ms vs 7,746 ms
286.4×
set_precision (pointwise)1,000,000 features · rg 16 ms vs 2,066 ms
125.5×
centroid10,000,000 features · rg 56 ms vs 6,849 ms
122.6×
intersects1,000,000 features · rg 35 ms vs 3,406 ms
98.3×
simplify (Douglas-Peucker)1,000,000 features · rg 38 ms vs 2,854 ms
75.7×
erase1,000,000 features · rg 139 ms vs 7,929 ms
57.0×
convex_hull1,000,000 features · rg 168 ms vs 8,400 ms
49.9×
length10,000,000 features · rg 24 ms vs 997 ms
42.4×
clip1,000,000 features · rg 143 ms vs 5,745 ms
40.3×
spatial_join (left)1,000,000 features · rg 12 ms vs 477 ms
40.2×
overlay (intersection)1,000,000 features · rg 554 ms vs 22,029 ms
39.8×
area10,000,000 features · rg 23 ms vs 855 ms
37.2×
buffer100,000 features · rg 43 ms vs 1,235 ms
28.5×
difference100,000 features · rg 66 ms vs 1,467 ms
22.4×
intersection100,000 features · rg 67 ms vs 1,442 ms
21.6×
sym_difference100,000 features · rg 76 ms vs 1,574 ms
20.8×
distance (points)10,000,000 features · rg 54 ms vs 1,024 ms
18.8×
dissolve (unary union)100,000 features · rg 182 ms vs 2,529 ms
13.9×

Bar lengths are log-scaled — speedups here span two orders of magnitude, and a linear bar would hide the smaller wins. Read the numbers; the bars are for shape.

Both engines timed on loaded data structures; results value-checked once per op in an untimed gate. overlay (intersection) is the two-layer table overlay — join + intersect + attributes from both layers; GeoPandas implements it natively and rg’s built-in overlay does the same work, so it counts like any other op. The largest gaps are uniform per-feature repair and reduction where rg’s columnar layout and parallelism outscale the per-object model; the low end is the heaviest shared constructive geometry. Identical work, matched N: each op is sized to the largest N the competitor completes within a fixed budget, and rg is timed on the same slice. Every result is output-verified before it is timed. Measured 2026-07-04/05 on a 16-core desktop (AMD Ryzen 9 7950X3D, 64 GB).

Engine 2 of 5 · R

vs sf — median 37.4×, 16 of 16 won.

R’s standard geospatial stack, GEOS-backed and single-threaded as shipped. sf 1.1-2 (current CRAN), GEOS 3.14.1. Speedups span 4.1× to 536.4×.

centroid5,000,000 features · rg 47 ms vs 25,251 ms
536.4×
length10,000,000 features · rg 42 ms vs 18,857 ms
445.4×
area10,000,000 features · rg 44 ms vs 13,607 ms
309.9×
spatial_join (left)1,000,000 features · rg 21 ms vs 5,757 ms
279.1×
simplify (Douglas-Peucker)1,000,000 features · rg 91 ms vs 8,251 ms
90.3×
erase1,000,000 features · rg 129 ms vs 8,699 ms
67.3×
clip1,000,000 features · rg 130 ms vs 7,422 ms
57.0×
intersects1,000,000 features · rg 88 ms vs 3,445 ms
39.3×
convex_hull1,000,000 features · rg 631 ms vs 22,468 ms
35.6×
distance (points)10,000,000 features · rg 36 ms vs 803 ms
22.4×
make_valid100,000 features · rg 86 ms vs 1,868 ms
21.7×
buffer100,000 features · rg 129 ms vs 2,264 ms
17.5×
difference100,000 features · rg 127 ms vs 1,396 ms
11.0×
intersection100,000 features · rg 218 ms vs 1,442 ms
6.6×
sym_difference100,000 features · rg 395 ms vs 1,895 ms
4.8×
dissolve (unary union)100,000 features · rg 425 ms vs 1,744 ms
4.1×

Bar lengths are log-scaled — speedups here span two orders of magnitude, and a linear bar would hide the smaller wins. Read the numbers; the bars are for shape.

s2 disabled; all operands planar EPSG:3857 — sf runs its fast GEOS path. The pairwise constructive ops run through sf’s native by_element API. This table has 16 rows to GeoPandas’s 18 because two Python-suite ops have no faithful sf counterpart and are omitted, never counted as wins: overlay (sf has no table-style overlay; st_intersection(sf, sf) is the join-style idiom) and set_precision (st_set_precision sets a precision model on the object rather than snapping coordinates). Identical work, matched N: each op is sized to the largest N sf completes within a fixed budget, and rg is timed on the same slice. Every result is output-verified before it is timed. The rg column is the R package’s own build (rtools/GNU toolchain), exactly as an R user gets it — it currently runs some constructive kernels slower than rg’s Python build (MSVC), so rg’s absolute times differ between this table and the Python one. Measured 2026-08-17 — later than the other tables because sf 1.1 added vectorized pairwise operations after the July runs, and we upgraded to current CRAN and re-measured the entire suite rather than publish against an outdated sf — on the same 16-core desktop (AMD Ryzen 9 7950X3D, 64 GB).

Engine 3 of 5 · SQL analytics

vs DuckDB Spatial — median 13.0×, 15 of 15 won.

The analytics database’s spatial extension, measured at 32 threads on loaded tables.

make_valid100,000 features · rg 27 ms vs 7,301 ms
266.0×
buffer100,000 features · rg 45 ms vs 1,240 ms
27.8×
intersection100,000 features · rg 65 ms vs 1,489 ms
23.0×
difference100,000 features · rg 66 ms vs 1,478 ms
22.4×
spatial_join (left)1,000,000 features · rg 14 ms vs 219 ms
16.2×
simplify (Douglas-Peucker)1,000,000 features · rg 37 ms vs 590 ms
16.1×
dissolve (unary union)100,000 features · rg 184 ms vs 2,590 ms
14.1×
convex_hull1,000,000 features · rg 161 ms vs 2,099 ms
13.0×
erase1,000,000 features · rg 132 ms vs 1,641 ms
12.4×
clip1,000,000 features · rg 134 ms vs 1,302 ms
9.7×
distance2,000,000 features · rg 11 ms vs 52 ms
4.7×
intersects1,000,000 features · rg 35 ms vs 122 ms
3.5×
length2,000,000 features · rg 3.8 ms vs 8.3 ms
2.2×
centroid2,000,000 features · rg 11 ms vs 18 ms
1.7×
area2,000,000 features · rg 4.2 ms vs 6.9 ms
1.6×

Bar lengths are log-scaled — speedups here span two orders of magnitude, and a linear bar would hide the smaller wins. Read the numbers; the bars are for shape.

DuckDB-spatial has no pointwise set_precision or ST_SymDifference — those two ops are unmapped, never counted as wins. The point-in-polygon predicate is measured on DuckDB’s prepared path so the comparison is prepared-vs-prepared. Each op runs in a fresh DuckDB process: a long-lived process slowed DuckDB’s later ops ∼8×, so per-op isolation is the configuration that favors DuckDB. Identical work, matched N: each op is sized to the largest N the competitor completes within a fixed budget, and rg is timed on the same slice. Every result is output-verified before it is timed. Measured 2026-07-04/05 on a 16-core desktop (AMD Ryzen 9 7950X3D, 64 GB).

Engine 4 of 5 · spatial database

vs PostGIS — median 11.7×, 17 of 17 won.

The reference spatial database, measured at full parallelism — 32 workers per query, spatial index in place, queries timed inside the server.

erase1,000,000 features · rg 139 ms vs 7,808 ms
56.3×
spatial_join (left)1,000,000 features · rg 12 ms vs 633 ms
53.9×
clip1,000,000 features · rg 137 ms vs 5,847 ms
42.7×
distance2,000,000 features · rg 12 ms vs 300 ms
26.0×
intersects1,000,000 features · rg 34 ms vs 798 ms
23.2×
make_valid100,000 features · rg 26 ms vs 492 ms
18.7×
dissolve (unary union)100,000 features · rg 180 ms vs 2,473 ms
13.7×
length2,000,000 features · rg 4.3 ms vs 52 ms
12.0×
area2,000,000 features · rg 4.6 ms vs 53 ms
11.7×
centroid2,000,000 features · rg 11 ms vs 112 ms
10.5×
set_precision1,000,000 features · rg 16 ms vs 48 ms
2.9×
convex_hull1,000,000 features · rg 159 ms vs 375 ms
2.4×
difference100,000 features · rg 64 ms vs 130 ms
2.0×
buffer100,000 features · rg 46 ms vs 92 ms
2.0×
intersection100,000 features · rg 68 ms vs 130 ms
1.9×
sym_difference100,000 features · rg 76 ms vs 132 ms
1.7×
simplify (Douglas-Peucker)1,000,000 features · rg 40 ms vs 62 ms
1.6×

Bar lengths are log-scaled — speedups here span two orders of magnitude, and a linear bar would hide the smaller wins. Read the numbers; the bars are for shape.

Tables loaded via COPY with per-table parallel_workers=32, worker counts verified per query with EXPLAIN ANALYZE; the grille point-in-polygon predicate uses a parallel partial aggregate so PostGIS runs it across workers rather than serially on the leader. Every engine starts from its already-loaded native format, and PostGIS is the only engine given a pre-built spatial index (GiST + ANALYZE, created untimed — its standard production setup); rg builds any spatial structures it needs inside each timed operation and holds no cached indexes between calls. Identical work, matched N: each op is sized to the largest N the competitor completes within a fixed budget, and rg is timed on the same slice. Every result is output-verified before it is timed. Measured 2026-07-04/05 on a 16-core desktop (AMD Ryzen 9 7950X3D, 64 GB).

Engine 5 of 5 · cluster engine

vs Apache Sedona — median 9.5×, 16 of 16 won.

A cluster engine measured on one machine: single-node Spark, local[*], 16 GB heap, data repartitioned across all cores, its broadcast index join used for predicates.

length2,000,000 features · rg 4.5 ms vs 297 ms
66.0×
dissolve (unary union)100,000 features · rg 178 ms vs 10,579 ms
59.3×
distance2,000,000 features · rg 12 ms vs 522 ms
45.2×
area2,000,000 features · rg 4.2 ms vs 186 ms
44.1×
spatial_join (left)1,000,000 features · rg 15 ms vs 305 ms
20.9×
centroid2,000,000 features · rg 11 ms vs 229 ms
20.6×
intersects1,000,000 features · rg 37 ms vs 732 ms
19.7×
difference100,000 features · rg 64 ms vs 625 ms
9.8×
simplify (Douglas-Peucker)1,000,000 features · rg 40 ms vs 374 ms
9.3×
make_valid100,000 features · rg 27 ms vs 250 ms
9.2×
intersection100,000 features · rg 73 ms vs 593 ms
8.2×
sym_difference100,000 features · rg 83 ms vs 485 ms
5.9×
buffer100,000 features · rg 47 ms vs 252 ms
5.4×
clip1,000,000 features · rg 117 ms vs 342 ms
2.9×
convex_hull1,000,000 features · rg 183 ms vs 470 ms
2.6×
erase1,000,000 features · rg 110 ms vs 276 ms
2.5×

Bar lengths are log-scaled — speedups here span two orders of magnitude, and a linear bar would hide the smaller wins. Read the numbers; the bars are for shape.

Single-node Spark is noisy run-to-run — isolated re-runs measured medians of 7.8–15.6×. Pointwise set_precision is unmapped, never counted as a win. Identical work, matched N: each op is sized to the largest N the competitor completes within a fixed budget, and rg is timed on the same slice. Every result is output-verified before it is timed. Measured 2026-07-04/05 on a 16-core desktop (AMD Ryzen 9 7950X3D, 64 GB).

Apache SpatialBench

Six million trips. Twelve spatial workloads. 3.21 seconds.

SpatialBench is an open benchmark for spatial analytics, published by the Apache Sedona project. SF1 holds 6,000,000 trip records, 156,095 zone polygons, 1.6 million buildings and supporting tables — about 1.8 GB of Parquet. Its 12 queries exercise the operations that make spatial engines hard to build: filters, distances, point-in-polygon joins, chained joins, grouped aggregation, dissolve, and nearest-neighbour search.

Official protocol

the benchmark’s own runner · a fresh process per query · every file read inside the clock · mean of 3
rgSpatialall 12 workloads · every answer correct
3.21 s
Apache SedonaDBfrom the project that created the benchmark — 12/12 · rg is 3.6× faster
11.68 s
PyCanopyRust spatial-index engine — 12/12, answers verified · rg is 3.8× faster
12.25 s
Spatial Polarsthe Polars ecosystem’s spatial extension — 12/12 · rg is 61× faster
194.5 s
DuckDB Spatial11 of 12 — q12 killed at the 600 s cap; the suite was never completed
DNF
12 / 12  workloads correct 100%  of result cells match the published answers 0  timings accepted without a verified answer

Same benchmark. Published queries. Published answers. One machine.Every rgSpatial result cell is validated against SpatialBench’s reference output (rtol 1e-6, positional) — a wrong answer would disqualify the timing entirely.

In memory — the analytical work alone

same runner, same fresh process · every table loaded before the clock starts · only the query timed

The official protocol times the file reads along with the analytics. This second reading removes them: the untimed setup loads every engine’s tables into that engine’s own in-memory form, and the clock starts on the query. The difference between the two readings is what each engine pays to get data off disk; this one is what it does with the data once it has it.

rgSpatialtables held as native GeoArrow — how rg ships
1.40 s
Apache SedonaDBtables as in-memory WKB — 12/12 · rg is 6.0× faster
8.42 s
PyCanopytables as in-memory WKB — 12/12 · rg is 7.9× faster
11.08 s
Spatial Polarstables as in-memory WKB — 12/12 · rg is 134× faster
187.8 s
DuckDB Spatial11 of 12 — q12 killed at the 600 s cap
DNF

rgSpatial leads every one of the 12 queries in this reading. Loading the tables first moves SedonaDB by about a quarter, PyCanopy by about a tenth and Spatial Polars almost not at all — their time is in the joins and in parsing WKB inside each query, not in the reads — while it more than halves rgSpatial’s, whose reads were already close to the operating system’s copy rate. Each engine holds its tables the way it ships: rgSpatial as native GeoArrow — a capability competitors lack, since SedonaDB rejects GeoArrow-native geometry and DuckDB-spatial cannot bind it — and the others as WKB bytes, with no index and no geometry pre-parse. Nothing is precomputed for anyone.

SpatialBench generates realistic synthetic data — a transportation-themed schema of points, polygons, buildings, and zones — reproducibly, at configurable scale factors.

Every competitor was timed by the benchmark’s own official runner: SedonaDB 0.5.0, DuckDB 1.5.6 + spatial, Spatial Polars 0.4.1, and PyCanopy 0.4.1 — each its latest release. Each engine was measured on the newest Python it supports — PyCanopy in a Python 3.12 environment, the ceiling its packaging declares, the rest on Python 3.13. SedonaDB 0.5.0 publishes no Windows build on PyPI, so it ran from conda-forge’s. Every query each engine completed verifies cell-for-cell against the committed answers. Every engine ran on the same 16-core desktop (AMD Ryzen 9 7950X3D, 64 GB): rgSpatial (rg 0.6.4), SedonaDB, DuckDB and PyCanopy were measured 2026-10-08, Spatial Polars 2026-09-25. Four runs of rgSpatial that day gave totals of 3.11–3.21 s, and three in memory 1.39–1.40 s; the charts show the slowest.

DuckDB completed 11 of 12 queries in 455 s (665 s with its tables in memory) and was killed at the 600-second cap on q12 in both readings — no complete-suite total exists, so we print none. Its two longest queries, q10 and q11, run for minutes and were timed once rather than three times.

Live in the Browser

No, really — SpatialBench runs in the browser.

The benchmarks above were run natively. But the same Rust engine also compiles to WebAssembly. Here it is live in the rgSpatial web app, running SpatialBench q1’s spatial work against all 6,000,000 trips: ST_DWithin keeps the trips within 50 km of Sedona, Arizona, ST_Distance ranks them, and the result lands on the map as a layer.

Real screen recording. Unedited. No server compute.

SELECT t_tripkey,
       geometry,
       ST_Distance(geometry, ST_GeomFromText('POINT (-111.7610 34.8697)')) AS distance_to_center
FROM sb_trip
WHERE ST_DWithin(geometry, ST_GeomFromText('POINT (-111.7610 34.8697)'), 50000)
ORDER BY distance_to_center ASC
LIMIT 100

Forty-five trips lie within 50 km — distances in geodesic metres, computed in a browser tab. The town is Sedona, Arizona; the benchmark is Apache Sedona’s; the engine is ours.

File I/O · reads

Reads: 5.7–141× faster than GeoPandas.

rg’s Rust readers against GeoPandas, the path Python users have today: the GDAL formats through pyogrio, on both its classic driver path and its Arrow batch path, with GDAL’s multithreading turned on, and GeoParquet and Feather through pyarrow. Both read the same file, and every bar is against whichever GeoPandas path is faster.

FileGDBrg 6.4 ms vs GeoPandas 903 ms
141×
GeoJSONrg 18 ms vs GeoPandas 1,154 ms
64×
KMLrg 43 ms vs GeoPandas 2,749 ms
64×
GeoJSON-Lrg 27 ms vs GeoPandas 1,530 ms
58×
KMZrg 65 ms vs GeoPandas 2,714 ms
42×
TopoJSONrg 38 ms vs GeoPandas 1,601 ms
42×
GPXrg 76 ms vs GeoPandas 2,816 ms
37×
Shapefilerg 24 ms vs GeoPandas 792 ms
32×
FlatGeobufrg 21 ms vs GeoPandas 538 ms
25×
Featherrg 8.2 ms vs GeoPandas 89 ms
11×
GeoParquetrg 11 ms vs GeoPandas 93 ms
8.2×
GeoPackagerg 20 ms vs GeoPandas 116 ms
5.7×

Bar lengths are log-scaled — speedups here span two orders of magnitude, and a linear bar would hide the smaller wins. Read the numbers; the bars are for shape.

N = 100,000 features per format. GeoPandas builds shapely geometries from what it reads, which is most of its cost. Each file is the reference’s own: GDAL writes the GDAL formats and GeoPandas the GeoParquet and Feather files, except TopoJSON, which is rg’s, since GDAL has no TopoJSON writer. Both write the same columns (GDAL’s KML through its LIBKML driver); GeoParquet and Feather use zstd at level 1 on both sides. Row-count correctness gate; median of five runs after a warmup, each in a fresh process. rg 0.6.5 against geopandas 1.2.0 on GDAL 3.13.3 (conda-forge, the newest release) and pyarrow 26.0.0. CSV and Excel are measured against pandas 3.0.6: CSV 8.8× read / 65× write, Excel 16× read / 94× write, pandas reading Excel through python-calamine and writing it through xlsxwriter. Measured 2026-10-11/12 on a 16-core desktop (AMD Ryzen 9 7950X3D, 64 GB) running Windows 11.

File I/O · writes

Writes: 4.5–280× faster than GeoPandas.

Same protocol, write direction: both engines write the same features, each to its own file, with the same codec and the rows in their given order.

GPXrg 34 ms vs GeoPandas 9,556 ms
280×
GeoJSONrg 13 ms vs GeoPandas 1,900 ms
144×
KMLrg 21 ms vs GeoPandas 2,644 ms
126×
FileGDBrg 21 ms vs GeoPandas 2,443 ms
115×
GeoJSON-Lrg 20 ms vs GeoPandas 1,871 ms
92×
Shapefilerg 16 ms vs GeoPandas 1,250 ms
79×
KMZrg 43 ms vs GeoPandas 2,618 ms
61×
FlatGeobufrg 20 ms vs GeoPandas 916 ms
45×
Featherrg 4.3 ms vs GeoPandas 144 ms
33×
GeoParquetrg 11 ms vs GeoPandas 173 ms
16×
GeoPackagerg 91 ms vs GeoPandas 409 ms
4.5×
TopoJSONrg 45 ms · GDAL has no TopoJSON writer
—

Bar lengths are log-scaled — speedups here span two orders of magnitude, and a linear bar would hide the smaller wins. Read the numbers; the bars are for shape.

GeoPandas converts its shapely geometries for every write. GPX write compares against GDAL’s classic driver (its Arrow write path is n/a for GPX). TopoJSON has no GDAL writer — rg’s 45 ms write is shown without a comparison bar. rg 0.6.5 against geopandas 1.2.0 on GDAL 3.13.3 (conda-forge, the newest release) and pyarrow 26.0.0. Measured 2026-10-11/12 on a 16-core desktop (AMD Ryzen 9 7950X3D, 64 GB) running Windows 11.

File I/O · GDAL’s Arrow interface

Against GDAL’s Arrow interface: reads 1.7–130× and writes 3.1–134× faster.

GDAL at its fastest, without GeoPandas and with its multithreading on: its Arrow stream and Arrow writer, through pyogrio’s read_arrow and write_arrow.

Reads
FileGDBrg 6.4 ms vs GDAL 832 ms
130×
KMLrg 43 ms vs GDAL 2,641 ms
61×
GeoJSONrg 18 ms vs GDAL 1,072 ms
59×
GeoJSON-Lrg 27 ms vs GDAL 1,452 ms
55×
KMZrg 65 ms vs GDAL 2,618 ms
40×
TopoJSONrg 38 ms vs GDAL 1,425 ms
37×
GPXrg 76 ms vs GDAL 2,731 ms
36×
Shapefilerg 24 ms vs GDAL 717 ms
29×
FlatGeobufrg 21 ms vs GDAL 441 ms
21×
Featherrg 3.1 ms vs GDAL 8.4 ms
2.7×
GeoParquetrg 5.2 ms vs GDAL 13 ms
2.5×
GeoPackagerg 20 ms vs GDAL 35 ms
1.7×
Writes
GeoJSONrg 13 ms vs GDAL 1,777 ms
134×
KMLrg 21 ms vs GDAL 2,512 ms
119×
FileGDBrg 21 ms vs GDAL 2,336 ms
110×
GeoJSON-Lrg 20 ms vs GDAL 1,737 ms
86×
Shapefilerg 16 ms vs GDAL 1,074 ms
68×
KMZrg 43 ms vs GDAL 2,507 ms
58×
FlatGeobufrg 20 ms vs GDAL 792 ms
39×
Featherrg 4.5 ms vs GDAL 28 ms
6.3×
GeoParquetrg 11 ms vs GDAL 61 ms
5.5×
GeoPackagerg 91 ms vs GDAL 285 ms
3.1×
GPXrg 34 ms · GDAL’s Arrow writer takes no GPX
—
TopoJSONrg 45 ms · GDAL has no TopoJSON writer
—

Bar lengths are log-scaled — speedups here span two orders of magnitude, and a linear bar would hide the smaller wins. Read the numbers; the bars are for shape.

Except for GeoParquet and Feather, GDAL’s reads return geometry still encoded as WKB, and rg’s return it decoded. GeoParquet and Feather use GDAL’s own Parquet and Arrow drivers (conda-forge, absent from the standard pyogrio wheel), with rg’s row groups and record batches, the same codec, no bbox column and the rows in their given order. The other files, the gate and the timing are as above. rg 0.6.5 against GDAL 3.13.3 (conda-forge, the newest release). Measured 2026-10-11/12 on a 16-core desktop (AMD Ryzen 9 7950X3D, 64 GB) running Windows 11.