Fast vector geoprocessing in Python: spatial indexes, overlays and modern file formats

Benchmark point-in-polygon joins, buffers, overlays and dissolves on 200,000 points with GeoPandas and Shapely 2, see why spatial indexes give a 100x+ speed-up, and compare Shapefile, GeoPackage, FlatGeobuf and GeoParquet.

Level
Intermediate
Tested with
Python 3.8
Source code
Download scripts

Key takeaways

  1. A spatially indexed join (Shapely STRtree or GeoPandas sjoin) assigned 202,062 points to 9,255 grid cells in 0.13–0.25 s, against about 40 s extrapolated for a naive loop, with identical results.
  2. Coarser buffers (fewer segments per quarter circle) have a quarter of the vertices, which makes every later overlay, union and write cheaper.
  3. For clean, non-overlapping tilings, shapely.coverage_union_all dissolved more than 5x faster than a standard dissolve.
  4. GeoParquet was about 5.5x smaller than Shapefile and fastest to read in full; GeoPackage was fastest for bounding-box reads; Shapefile came last on every measure.

Before you start

  • Comfortable with GeoPandas basics (see the GeoPandas getting-started tutorial)
  • Python 3.8+ with geopandas 0.13+, shapely 2.0+, pyogrio and pyarrow
  • About 200 MB of free disk space for the format comparison

GeoPandas code that works on a 500-row sample often grinds to a halt on real data. The cause is almost always the same: a Python loop where a vectorised, spatially indexed operation should be. Since Shapely 2.0, every geometry operation runs on whole NumPy arrays in C (GEOS), and GeoPandas uses it automatically.

This tutorial measures the difference rather than just asserting it. We will:

  1. generate a realistic dataset: about 200,000 “incident” points clustered around British towns, and a 5 km analysis grid;
  2. assign points to grid cells three ways (naive loop, STRtree, sjoin) and time each;
  3. look at the speed/accuracy trade-off for buffers;
  4. run an overlay and two flavours of dissolve;
  5. write and read the same data as Shapefile, GeoPackage, FlatGeobuf and GeoParquet.

Every timing below comes from a single run of vector_performance.py on a Windows 11 laptop. Your absolute numbers will differ; the ratios are what matter.

Output
=== versions ===
geopandas 0.13.2 | shapely 2.0.7 | GEOS 3.11.4

Step 1: build a test dataset

Synthetic data lets you control the size and still get a realistic spatial distribution. We take the British towns from Natural Earth’s 1:10m populated places and scatter 250,000 points around them, weighted by population with an 8 km Gaussian spread. A quarter of the points are spread uniformly as background noise. Everything is in British National Grid (EPSG:27700), so distances are in metres.

vector_performance.py
countries = gpd.read_file(fetch("ne_50m_admin_0_countries.zip"), engine="pyogrio")
uk = countries.loc[countries.ADM0_A3 == "GBR", ["geometry"]].to_crs(27700)
gb = uk.explode(index_parts=False).reset_index(drop=True) # parts share one label until reset
gb = gb.loc[[gb.area.idxmax()]] # the island of Great Britain only
places = gpd.read_file(fetch("ne_10m_populated_places.zip"), engine="pyogrio")
towns = places.loc[places.ADM0_A3 == "GBR"].to_crs(27700).clip(gb)
N = 250_000
rng = np.random.default_rng(42)
w = towns.POP_MAX.to_numpy(float)
centre = rng.choice(len(towns), size=N, p=w / w.sum())
xy = np.column_stack([towns.geometry.x.to_numpy()[centre], towns.geometry.y.to_numpy()[centre]])
xy += rng.normal(scale=8_000, size=(N, 2))
noise = rng.random(N) < 0.25 # a quarter of points spread uniformly
minx, miny, maxx, maxy = gb.total_bounds
xy[noise] = np.column_stack([rng.uniform(minx, maxx, noise.sum()), rng.uniform(miny, maxy, noise.sum())])
points = gpd.GeoDataFrame(
{"id": np.arange(N), "category": rng.choice(["burglary", "vehicle", "criminal damage", "theft"], N),
"value": rng.gamma(2.0, 50.0, N).round(2)},
geometry=gpd.points_from_xy(xy[:, 0], xy[:, 1]), crs=27700)
points = points[points.intersects(gb.geometry.iloc[0])].reset_index(drop=True)

There are two vectorised tricks already here. gpd.points_from_xy builds all the points in one call. points.intersects(polygon) tests all 250,000 points against Great Britain at once and keeps only those on land.

The grid is also built without a loop. shapely.box accepts arrays of coordinates:

cell = 5_000
gx, gy = np.meshgrid(np.arange(minx // cell * cell, maxx, cell), np.arange(miny // cell * cell, maxy, cell))
grid = gpd.GeoDataFrame(geometry=shapely.box(gx.ravel(), gy.ravel(), gx.ravel() + cell, gy.ravel() + cell),
crs=27700)
grid = grid[grid.intersects(gb.geometry.iloc[0])].reset_index(drop=True)
grid["cell_id"] = np.arange(len(grid))
grid["region"] = ((grid.geometry.bounds.minx // 100_000).astype(int).astype(str) + "-"
+ (grid.geometry.bounds.miny // 100_000).astype(int).astype(str))
Output
=== test data ===
towns used as cluster centres: 48
points: 202,062 grid cells: 9,255 regions: 43
id category value geometry
0 0 burglary 134.29 POINT (330043.351 657750.024)
1 1 criminal damage 101.07 POINT (389617.801 296168.657)
2 2 burglary 146.95 POINT (438262.667 513892.769)
3 3 vehicle 35.88 POINT (350664.759 393165.710)
4 4 burglary 85.89 POINT (203502.102 871590.272)

Of the 250,000 generated points, 202,062 fall on Great Britain; the rest fell offshore. The region column is a 100 km grid-square code that we will dissolve on later.

Step 2: point-in-polygon, three ways

The task is to find which of the 9,255 cells contains each point.

The naive loop

This is the version most people write first: for each point, test every polygon.

sample = points.iloc[:2_000]
naive = []
for pt in sample.geometry:
hit = grid.index[grid.contains(pt)]
naive.append(hit[0] if len(hit) else -1)

grid.contains(pt) is itself vectorised, but it still performs 9,255 full geometry tests per point. We only time 2,000 points and extrapolate the rest.

A Shapely STRtree

An STRtree (Sort-Tile-Recursive R-tree) stores each polygon’s bounding box in a balanced tree. A query first finds the handful of candidate boxes that overlap the point, then runs the exact predicate on those candidates only. Shapely 2 can query an entire array of geometries at once and returns pairs of indices:

tree = shapely.STRtree(grid.geometry.values.data)
pt_idx, poly_idx = tree.query(points.geometry.values.data, predicate="within")

GeoPandas sjoin

sjoin builds the same STRtree internally (grid.sindex) and returns a joined GeoDataFrame:

joined = gpd.sjoin(points, grid[["cell_id", "geometry"]], how="inner", predicate="within")
Output
=== point-in-polygon ===
naive loop (2,000 points) 0.393 s
-> extrapolated to all points 39.7 s
STRtree.query, all points 0.130 s
gpd.sjoin, all points 0.246 s
matched 202,062 of 202,062 points; all three methods agree
sjoin speed-up over naive loop: ~161x
Horizontal bar chart on a log scale: the naive loop takes 39.74 seconds (extrapolated), shapely STRtree query 0.13 seconds and geopandas sjoin 0.25 seconds.
The same 202,062-point join three ways (log scale). The spatial index turns a half-minute job into a fraction of a second. sjoin costs a little more than the raw STRtree because it also builds the joined DataFrame.

The script asserts that all three methods return the same assignments, so the speed-up costs nothing in correctness. Counting points per cell is then an ordinary pandas groupby:

counts = joined.groupby("cell_id").size().rename("n_points")
grid = grid.join(counts, on="cell_id").fillna({"n_points": 0}).astype({"n_points": int})
Output
cell_id region n_points
1532 1532 5-1 3137
1593 1593 5-1 3057
1531 1531 5-1 2935
1592 1592 5-1 2873
1533 1533 5-1 2235

The five busiest cells are all in 100 km square 5-1 (eastings 500–600 km, northings 100–200 km), which is central London.

Map of Great Britain as 5 km grid cells coloured in seven natural-breaks classes from pale yellow (0–52 points) to black (2,235–3,137 points). Dark clusters mark London, Birmingham, Manchester, Leeds, Glasgow and other towns.
Points per 5 km cell after the sjoin, classified with natural breaks (seven classes).

Step 3: buffers and quad_segs

A buffer around a point is a polygon that approximates a circle. The resolution argument (called quad_segs in Shapely) sets how many segments make up each quarter circle:

subset = points.iloc[:50_000]
b16 = subset.buffer(500) # default: 16 segments per quarter circle
b4 = subset.buffer(500, resolution=4)
Output
=== buffers ===
buffer 500 m, quad_segs=16 (default) 0.442 s
buffer 500 m, quad_segs=4 0.318 s
vertices per buffer: 65 vs 17; area error of quad_segs=4: 2.55%

Creating the buffers is only about 30 % faster, but each polygon has a quarter of the vertices. Every downstream overlay, union and file write gets cheaper as a result. The coarse buffer under-estimates the circle’s area by 2.55 %. Whether that matters depends on your analysis. For a “within roughly 500 m” screen it rarely does.

Step 4: unions and overlays

Next, we build “hotspot zones” by buffering 20,000 burglary points by 1 km and merging the overlaps. Then we cut those zones by the grid with overlay:

burglary = points[points.category == "burglary"].iloc[:20_000]
zones = gpd.GeoDataFrame(geometry=[burglary.buffer(1_000, resolution=8).unary_union], crs=27700)
zones = zones.explode(index_parts=False).reset_index(drop=True)
inter = gpd.overlay(grid[["cell_id", "region", "geometry"]], zones, how="intersection")
inter["area_km2"] = inter.area / 1e6
cover = inter.groupby("cell_id").area_km2.sum()
Output
=== overlay ===
buffer + unary_union (20,000 x 1 km) 1.761 s
merged into 3,585 hotspot polygons
overlay(grid, zones, 'intersection') 0.470 s
8,373 intersection pieces; 659 cells are more than half covered

unary_union is the expensive step: 20,000 overlapping circles have to be noded together. overlay is fast because it also uses a spatial index, so it only intersects cell–zone pairs whose bounding boxes overlap.

Close-up of a 100 by 100 km area showing a light grid of 5 km cells. Coloured intersection pieces range from purple (small slivers around isolated points) to yellow (cells fully covered by merged hotspots) over Manchester, Leeds/Bradford and Sheffield. Orange dots show the burglary points.
The overlay result around Manchester (left), Leeds/Bradford (top) and Sheffield (bottom). Each piece is coloured by the area of hotspot zone it contains: isolated points give small purple discs, while dense urban clusters fill whole 25 km² cells.

Step 5: dissolve, and a faster union for coverages

dissolve groups rows and unions their geometries. For polygons that tile without overlapping, such as our grid, Shapely 2 offers coverage_union_all. It assumes the input is a valid coverage (no overlaps), so it can simply drop the shared edges:

regions = grid.dissolve(by="region", aggfunc={"n_points": "sum"})
cov = {r: shapely.coverage_union_all(g.geometry.values.data) for r, g in grid.groupby("region")}
Output
=== dissolve ===
dissolve(by='region') [unary_union] 0.197 s
coverage_union_all per region 0.035 s
43 regions; max area difference between methods: 0.000000 m²

The two methods give identical results, but coverage_union_all was more than 5x faster here.

Step 6: choosing a file format

Finally, we write the 202,062 points to four formats. For each one we time a full write, a full read, and a bounding-box read of a 30 × 30 km window around Manchester and Sheffield. The bounding-box read is the operation a web map or a tiled analysis performs.

query_box = box(420_000, 420_000, 450_000, 450_000)
points.to_file("points.gpkg", driver="GPKG", engine="pyogrio") # .shp / .fgb are the same
points.to_parquet("points.parquet")
sub = gpd.read_file("points.gpkg", engine="pyogrio", bbox=query_box.bounds)
sub = gpd.read_parquet("points.parquet"); sub = sub[sub.intersects(query_box)] # gpd 0.13
Output
=== file formats ===
size_MB write_s read_s bbox_read_s bbox_rows
format
Shapefile 30.3 2.32 1.31 1.430 9006
GeoPackage 23.7 0.71 0.50 0.057 9006
FlatGeobuf 29.6 1.58 1.01 0.080 9006
GeoParquet 5.4 0.17 0.17 0.180 9006
Three bar charts comparing Shapefile, GeoPackage, FlatGeobuf and GeoParquet. File size: 30.3, 23.7, 29.6 and 5.4 MB. Write time: 2.32, 0.71, 1.58 and 0.17 s. Full read time: 1.31, 0.50, 1.01 and 0.17 s.
File size, write time and full read time for the same 202,062 points. GeoParquet is the smallest and fastest by a wide margin.
Notebook-style table of the format benchmark, with columns size_MB, write_s, read_s, bbox_read_s and bbox_rows for Shapefile, GeoPackage, FlatGeobuf and GeoParquet.
The benchmark table, rendered from the script's formats_table.html in a notebook-style frame. Every format returned the same 9,006 points for the bounding-box query.

The results tell a clear story:

  • GeoParquet is columnar and compressed. It is about 5.5x smaller than a Shapefile and 7–8x faster to read in full. It is the best choice for analysis pipelines and cloud storage. With geopandas 0.13 a bbox read means reading everything and then filtering. geopandas ≥ 1.0 adds read_parquet(bbox=...), which uses GeoParquet 1.1 bbox covering columns to skip row groups.
  • GeoPackage has a built-in R-tree, which makes it the fastest at bbox queries here (0.057 s). It is a single file, is the OGC standard and QGIS handles it well. It is the best desktop and interchange format.
  • FlatGeobuf also has a spatial index (0.080 s bbox read) and is designed to be streamed over HTTP: a web client can fetch only the bytes for one area from a static file. It is not compact, though.
  • Shapefile came last on every measure, and also has a 10-character field-name limit, a 2 GB size limit and needs at least three sidecar files. Use it only when a recipient insists.

Summary: a performance checklist

  1. Never loop over geometries in Python. Look for the vectorised method on the GeoSeries, or a shapely.* array function.
  2. Use a spatial index for any “which A is in or near which B” question: sjoin, sjoin_nearest, sindex.query or STRtree.
  3. Work in a projected CRS with metre units before buffering or measuring.
  4. Reduce vertices early: coarser buffers, simplify(), or clipping to your study area.
  5. Use coverage_union_all to dissolve clean, non-overlapping tilings.
  6. Store intermediates as GeoParquet; deliver as GeoPackage, or FlatGeobuf for the web.

Next steps

Data and licences

  • Natural Earth 1:50m countries and 1:10m populated places: public domain, naturalearthdata.com.
  • The incident points are synthetic, generated by the script with a fixed random seed (42). They are not real crime data.
  • Code: vector_performance.py, tested with Python 3.8.10, geopandas 0.13.2, shapely 2.0.7, pyogrio 0.9.0 and pyarrow 17.0.0.

Key terms

Spatial index
A data structure (here an STRtree, a kind of R-tree) that stores bounding boxes so a query only runs exact geometry tests on nearby candidates.
Vectorised operation
One call that processes whole arrays of geometries in compiled code, instead of a Python loop over individual features.
Coverage
A set of polygons that tile an area without overlaps, such as a grid or administrative boundaries.
GeoParquet
A columnar, compressed file format for vector data built on Apache Parquet, suited to analysis pipelines and cloud storage.

Tutorial Python Part 2 of 3

Spatial joins in GeoPandas: sjoin, predicates, sjoin_nearest and point in polygon

A practical guide to spatial joins in GeoPandas with real Greater Manchester data: 13,187 NaPTAN bus stops, 1,386 OpenStreetMap schools, 215 ONS wards and 1,702 LSOAs. Covers point in polygon with sjoin, predicates, counts per polygon, how='left'/'inner'/'right', sjoin_nearest with max_distance, clip and overlay, and a timing comparison of a naive loop against the spatial index.

Beginner 12 min read

Three panels of the same 1 km by 0.7 km area at Salford Quays. Left: square WorldCover pixels, mostly red built-up land with blue dock basins and small green and yellow patches. Middle: the same area as 142 polygons with black outlines that follow the pixel staircase. Right: the polygons after coverage simplification at 10 m, with smoother outlines and no gaps between them.

Tutorial Python Part 3 of 3

Raster to vector in Python: polygonize with rasterio, rasterize boundaries and convert a GeoTIFF to a Cloud Optimized GeoTIFF

Convert raster to vector in Python with rasterio.features.shapes on 10 m ESA WorldCover for Greater Manchester: polygonize water and built-up land, fix invalid polygons, simplify without gaps, rasterize ONS district boundaries for land-cover shares, then write and validate a Cloud Optimized GeoTIFF with rio-cogeo and measure HTTP range reads.

Intermediate 14 min read

Four panels showing how Voronoi polygons are built for 47 rain gauges around the River Eden catchment: the gauges and the catchment outline, their Delaunay triangulation, the triangle circumcentres joined into red Voronoi edges, and the cells clipped to the catchment and shaded by each gauge's rainfall.

Tutorial Python

Voronoi (Thiessen) polygons in Python: catchment rainfall from EA rain gauges and fire station areas in Cumbria

Build Voronoi (Thiessen) polygons in Python with shapely and GeoPandas, clipped to a real boundary. Compute Thiessen-weighted catchment rainfall for the River Eden from Environment Agency rain gauge readings, check the gauges, compare with the gauge average and IDW, then compare Voronoi 'nearest fire station' areas in Cumbria with drive-time service areas from OpenStreetMap.

Intermediate 15 min read