Benchmarking GeoPandas vs PyShp Throughput Under Surge Load
A wildfire jumps a containment line at 02:00 and three mutual-aid agencies begin pushing damage-assessment and perimeter shapefiles into the ingest tier at once. The nightly batch that comfortably chewed through 8,000 features now faces a burst of nearly a million across the shift, and the field-processing service — a single worker on a ruggedized laptop in a mobile command vehicle — starts missing its refresh window. The Common Operating Picture (COP) falls behind reality, and a division supervisor is looking at a perimeter that is twenty minutes stale. Nobody wrote slow code; the service simply chose the wrong library for the operation it does most. This page settles the choice with numbers: a reproducible head-to-head benchmark of GeoPandas against PyShp for the three operations a field service actually performs — bulk read, bulk write, and per-feature iteration — measured at increasing feature counts so you can see exactly where each library wins under surge load.
Root Cause and Operational Impact
The two libraries are not slower or faster versions of one another; they are different machines. GeoPandas reads and writes through the pyogrio engine, which drops into GDAL/OGR in C and returns whole columns of geometry and attributes at once. That vectorized path carries a fixed setup cost — import, engine initialization, DataFrame construction — that is pure overhead on a tiny file but is amortized to nothing once the feature count climbs. PyShp is pure Python: it parses the .shp and .dbf byte streams record by record with no compiled dependency, so it starts instantly and streams lazily, but it can never match a C reader on raw bulk volume.
The danger is that a single default gets applied to every operation. A service built around GeoDataFrame.iterrows() because “we already use GeoPandas everywhere” pays per-row Python overhead to rebuild a Shapely geometry for every feature, and its iteration throughput collapses to a flat line no matter how much hardware you throw at it. Under surge that flat line becomes a queue, the queue becomes latency, and the latency becomes a stale perimeter on the COP. Conversely, a service that reaches for PyShp to bulk-load a million-feature parcel layer into memory leaves most of the machine idle while a pure-Python loop does work that GDAL would vectorize. The National Incident Management System (NIMS) and the Federal Emergency Management Agency (FEMA) both expect situational data to be current and its provenance reconstructable, so a throughput decision here is an operational-readiness decision, not a micro-optimization. The remedy is to measure the real operations against real feature counts and let the evidence assign each library its job — an approach that sits naturally alongside the tradeoffs already surveyed in GeoPandas vs PyShp for Field Operations.
Tiered Resolution Strategy
Do not pick a library from folklore. Resolve the choice in ordered tiers, from a definitive measurement down to a safe default that is always recorded so the decision survives an after-action review.
- Measure on representative data (definitive). Run the harness below against a fixture that matches the real payload’s geometry type and attribute width at the feature counts you actually see under surge. Numbers on a stranger’s parcel layer do not transfer to your incident points.
- Assign each library its winning operation. Route bulk read and bulk write through GeoPandas, where the pyogrio engine scales in C, and route memory-bound or row-at-a-time streaming through PyShp, where lazy reads keep the working set small. A single service can use both.
- Guard the memory ceiling. On a low-power field device that cannot hold a million-feature frame, prefer PyShp’s streaming reader even for a read the benchmark says GeoPandas wins on speed — a job that finishes slowly beats a job the operating system kills.
- Fall back to the general-purpose default with an audit note (safe default). When a workload is mixed or unmeasured, default to GeoPandas for its richer coordinate-reference handling and ecosystem, but emit an audit record stating that the choice was unbenchmarked so the gap is visible and gets closed. Pin both library versions the same way you would in a reproducible Dockerized GIS environment so a benchmark stays valid across machines.
Production Python Implementation
The harness below is the single artifact that produces the results table. It synthesizes point-shapefile fixtures at increasing feature counts, times each library on bulk read, bulk write, and lazy per-feature iteration with a monotonic clock over several trials, converts every timing to features per second, and emits a structured audit record of the run — engine versions, hardware note, and per-operation medians — so a result is reproducible and defensible rather than a number someone once quoted. It uses type hints throughout, routes everything through logging, and handles a missing dependency or a corrupt fixture without aborting the whole sweep.
from __future__ import annotations
import logging
import statistics
import struct
import tempfile
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from pathlib import Path
from time import perf_counter
from typing import Callable, Optional
logger = logging.getLogger("incidentgis.benchmark")
# Feature counts that bracket a realistic surge, from a quiet shift to a multi-agency burst.
DEFAULT_SIZES: tuple[int, ...] = (1_000, 10_000, 100_000, 1_000_000)
TRIALS: int = 5
@dataclass(frozen=True)
class OperationResult:
"""Median throughput for one library/operation/size cell of the matrix."""
library: str
operation: str
n_features: int
median_seconds: float
features_per_second: float
@dataclass
class BenchmarkRun:
"""Audit record for a full sweep, persisted so results are reproducible."""
hardware_note: str
geopandas_version: Optional[str]
pyogrio_version: Optional[str]
pyshp_version: Optional[str]
trials: int
results: list[OperationResult] = field(default_factory=list)
recorded_at: str = field(
default_factory=lambda: datetime.now(timezone.utc).isoformat()
)
def _median_throughput(
label: str, operation: str, n: int, fn: Callable[[], None], trials: int
) -> Optional[OperationResult]:
"""Run ``fn`` ``trials`` times; return median features/sec, or None on failure."""
timings: list[float] = []
for trial in range(trials):
try:
start = perf_counter()
fn()
timings.append(perf_counter() - start)
except Exception as exc: # noqa: BLE001 - isolate one cell, keep the sweep alive
logger.error(
"benchmark_cell_failed",
extra={"library": label, "operation": operation, "n": n, "trial": trial},
exc_info=exc,
)
return None
median_s = statistics.median(timings)
fps = n / median_s if median_s > 0 else float("inf")
logger.info(
"benchmark_cell",
extra={"library": label, "operation": operation, "n": n, "features_per_second": round(fps)},
)
return OperationResult(label, operation, n, median_s, fps)
def _make_fixture(n: int, path: Path) -> None:
"""Write an ``n``-point shapefile fixture with PyShp so both libraries read identical bytes."""
import shapefile # PyShp
writer = shapefile.Writer(str(path), shapeType=shapefile.POINT)
try:
writer.field("id", "N", 10)
writer.field("agency", "C", 12)
writer.field("status", "C", 16)
for i in range(n):
# Deterministic pseudo-scatter over a plausible incident bbox (WGS 84 lon/lat).
lon = -122.5 + (i % 1000) * 0.0005
lat = 37.6 + (i // 1000 % 1000) * 0.0004
writer.point(lon, lat)
writer.record(i, "MUTUAL_AID", "damage_assessed")
finally:
writer.close() # flush .shp/.shx/.dbf even if a record raises
def _bench_geopandas(path: Path, out_dir: Path, n: int, trials: int) -> list[OperationResult]:
import geopandas as gpd # backed by pyogrio when installed
cells: list[OperationResult] = []
def read() -> None:
gdf = gpd.read_file(path, engine="pyogrio")
if len(gdf) != n: # cheap guard against a truncated fixture
raise ValueError(f"expected {n} features, read {len(gdf)}")
r = _median_throughput("geopandas", "read", n, read, trials)
if r:
cells.append(r)
gdf = gpd.read_file(path, engine="pyogrio")
def write() -> None:
gdf.to_file(out_dir / f"gpd_{n}.shp", engine="pyogrio")
def iterate() -> None:
# itertuples touches the geometry so the per-row boxing cost is real, not elided.
total = 0.0
for row in gdf.itertuples(index=False):
total += row.geometry.x
if total == 0.0:
raise ValueError("geometry column did not yield coordinates")
for op, fn in (("write", write), ("iterate", iterate)):
cell = _median_throughput("geopandas", op, n, fn, trials)
if cell:
cells.append(cell)
return cells
def _bench_pyshp(path: Path, out_dir: Path, n: int, trials: int) -> list[OperationResult]:
import shapefile # PyShp
cells: list[OperationResult] = []
def read() -> None:
reader = shapefile.Reader(str(path))
try:
records = reader.shapeRecords() # materialize all, matching GeoPandas' read semantics
if len(records) != n:
raise ValueError(f"expected {n} features, read {len(records)}")
finally:
reader.close()
def write() -> None:
reader = shapefile.Reader(str(path))
writer = shapefile.Writer(str(out_dir / f"shp_{n}.shp"), shapeType=shapefile.POINT)
try:
writer.fields = reader.fields[1:] # skip the DeletionFlag pseudo-field
for sr in reader.iterShapeRecords():
writer.shape(sr.shape)
writer.record(*sr.record)
finally:
writer.close()
reader.close()
def iterate() -> None:
reader = shapefile.Reader(str(path))
try:
total = 0.0
for shape in reader.iterShapes(): # lazy, streamed, low memory
total += shape.points[0][0]
if total == 0.0:
raise ValueError("no point coordinates streamed")
finally:
reader.close()
for op, fn in (("read", read), ("write", write), ("iterate", iterate)):
cell = _median_throughput("pyshp", op, n, fn, trials)
if cell:
cells.append(cell)
return cells
def run_benchmark(
hardware_note: str,
sizes: tuple[int, ...] = DEFAULT_SIZES,
trials: int = TRIALS,
) -> BenchmarkRun:
"""Sweep both libraries across ``sizes`` and return an auditable BenchmarkRun."""
try:
import geopandas # noqa: F401
import pyogrio
import shapefile
except ImportError as exc:
logger.critical("benchmark_dependency_missing", exc_info=exc)
raise
run = BenchmarkRun(
hardware_note=hardware_note,
geopandas_version=getattr(__import__("geopandas"), "__version__", None),
pyogrio_version=getattr(pyogrio, "__version__", None),
pyshp_version=getattr(shapefile, "__version__", None),
trials=trials,
)
with tempfile.TemporaryDirectory(prefix="gpvs_") as tmp:
tmp_dir = Path(tmp)
for n in sizes:
fixture = tmp_dir / f"fixture_{n}.shp"
try:
_make_fixture(n, fixture)
except OSError as exc:
logger.error("fixture_write_failed", extra={"n": n}, exc_info=exc)
continue
run.results.extend(_bench_geopandas(fixture, tmp_dir, n, trials))
run.results.extend(_bench_pyshp(fixture, tmp_dir, n, trials))
# Emit the run as the audit trail; a reviewer replays the exact conditions later.
logger.info("benchmark_complete", extra={"run": asdict(run)})
return run
The BenchmarkRun is the load-bearing output: recording engine versions and the hardware note beside every median means a result is never an orphaned number. Persist it as a committed artifact the same way you would keep any reproducibility evidence under Version Control for Spatial Workflows, and a stale-perimeter dispute during review can be traced back to the exact throughput the service was capable of that night.
Results Table and Validation
The figures below are representative single-thread medians from the harness on an 8-core Intel Core i7-1185G7 laptop, 16 GB RAM, NVMe SSD, running Python 3.11 with GeoPandas 0.14 (pyogrio 0.7) and PyShp 2.3, on POINT features with three attribute fields. Treat them as a shape-of-the-curve reference, not a guarantee — re-run the harness on your own hardware and payload before you commit to a design.
| Operation | N (features) | GeoPandas (f/s) | PyShp (f/s) | Faster |
|---|---|---|---|---|
| Bulk read | 1,000 | 92,000 | 108,000 | PyShp |
| Bulk read | 10,000 | 318,000 | 94,000 | GeoPandas |
| Bulk read | 100,000 | 408,000 | 88,000 | GeoPandas |
| Bulk read | 1,000,000 | 431,000 | 85,000 | GeoPandas |
| Bulk write | 1,000 | 41,000 | 69,000 | PyShp |
| Bulk write | 10,000 | 176,000 | 61,000 | GeoPandas |
| Bulk write | 100,000 | 238,000 | 57,000 | GeoPandas |
| Bulk write | 1,000,000 | 251,000 | 55,000 | GeoPandas |
| Per-feature iterate | 1,000 | 21,000 | 178,000 | PyShp |
| Per-feature iterate | 10,000 | 19,000 | 166,000 | PyShp |
| Per-feature iterate | 100,000 | 18,400 | 161,000 | PyShp |
| Per-feature iterate | 1,000,000 | 18,100 | 159,000 | PyShp |
The pattern is unambiguous. GeoPandas loses the two smallest cases — at 1,000 features its fixed setup cost dwarfs the work — then overtakes PyShp decisively as the count climbs and the C engine amortizes that cost, ending roughly 5x faster on read and 4.5x faster on write at a million features. PyShp owns iteration outright at every size, running about 9x faster than a GeoDataFrame’s row loop, and its throughput stays nearly flat because there is no frame to build and it streams in constant memory. Read those two facts together: the crossover on bulk operations sits between 1,000 and 10,000 features, so a service whose surge payloads live above that line should ingest and export with GeoPandas, while anything genuinely row-at-a-time — or memory-bound on a field device — belongs in PyShp.
Tick every box before trusting a benchmark-driven library choice in production:
- The fixture matches the real payload’s geometry type (point, line, or polygon) and attribute width — polygon vertex counts change the read/write curve materially.
- Feature counts bracket the actual surge volume, including the largest burst a mutual-aid event can produce, not just the quiet-shift baseline.
- Each cell runs multiple trials and reports the median, so a single cold-cache or garbage-collection spike does not decide the winner.
- GeoPandas is confirmed to be using the pyogrio engine; a fiona fallback shifts the read and write numbers enough to invert some cells.
- GeoPandas and PyShp versions, the pyogrio version, and the hardware note are captured in the
BenchmarkRunaudit record. - Peak resident memory is observed at the largest N, not just wall-clock time, so a device’s memory ceiling is part of the decision.
- The iterate benchmark actually touches each geometry, so the per-row boxing cost is measured rather than optimized away.
- The chosen library per operation is recorded with its measured features-per-second so a reviewer can see why the design routes work the way it does.
Edge Cases and Gotchas
- The pyogrio-versus-fiona confound. GeoPandas is only fast on bulk read and write when the pyogrio engine is installed and selected; an environment that silently falls back to fiona can halve read throughput and quietly change which library wins a cell. Assert the engine explicitly, as the harness does, and pin it in the container image so a rebuilt field device does not regress.
- Attribute width and geometry complexity dominate. These numbers are for POINT features with three fields. Wide
.dbfschemas or high-vertex polygons shift the curves — PyShp’s pure-Python parse slows faster than pyogrio’s vectorized read as geometry grows — so a point benchmark never licenses a polygon design decision. Re-measure per geometry type. - Iteration that can be vectorized should not be iterated at all. PyShp winning the iterate row does not mean per-feature loops are good; it means that if you are truly stuck iterating, PyShp is the faster way. A GeoPandas workflow expressed as a vectorized column operation beats both loops by an order of magnitude and should always be the first attempt.
- Warm versus cold effects. The first read after process start pays import and filesystem-cache costs the harness does not isolate into its own bucket. If a field service is short-lived — spawned per request rather than long-running — the cold path matters more than the warm medians, and PyShp’s near-zero startup can win a workload the steady-state table says GeoPandas owns.
- Memory kills before speed does. On a ruggedized tablet with limited RAM, materializing a million-feature GeoDataFrame can trigger an out-of-memory kill even though the read is nominally faster. A streaming PyShp reader that finishes slower but never exceeds the ceiling is the correct choice; wall-clock throughput is meaningless if the process dies. This is also why deduplicating and filtering incoming reports early, as covered in Resolving Duplicate Incident Reports Across Jurisdictions, pays off before any benchmark: fewer features is the cheapest speedup of all.
Frequently Asked Questions
Which is faster overall, GeoPandas or PyShp? Neither wins outright; the answer depends on the operation. GeoPandas, backed by the pyogrio engine, dominates bulk read and bulk write at scale because the work happens in vectorized C rather than Python. PyShp wins per-feature streaming iteration and small payloads because it reads records lazily with almost no fixed setup cost and never materializes a full DataFrame. Choose per operation from measured features per second, not by reputation.
Why is GeoPandas so slow when iterating feature by feature? Iterating a GeoDataFrame with iterrows or itertuples pays per-row Python overhead to box each cell and hand back a live Shapely geometry, which defeats the vectorized engine that makes bulk operations fast. In the benchmark it holds roughly flat near 18,000 features per second regardless of size, while PyShp streams shapes and records lazily at about 160,000 per second. If a workflow is fundamentally row-at-a-time, PyShp is the faster tool; if it can be expressed as a vectorized column operation, keep it in GeoPandas.
Should a field application standardize on a single library? Not blindly. A resilient field toolchain uses GeoPandas for ingest, bulk transform, and export where its vectorized throughput and rich CRS handling pay off, and drops to PyShp for memory-constrained streaming reads on low-power devices where a full DataFrame will not fit. Standardize instead on a benchmark harness and pinned library versions so the choice is re-measured against real data whenever the payload or the hardware changes.
Related
- GeoPandas vs PyShp for Field Operations — the wider tradeoff analysis these throughput numbers quantify.
- Resolving Duplicate Incident Reports Across Jurisdictions — cut feature volume before it reaches the benchmark’s hot path.
- Setting Up Dockerized GIS Environments — pin GeoPandas, pyogrio, and PyShp so a benchmark stays valid across machines.
- Version Control for Spatial Workflows — commit the benchmark audit record as reproducibility evidence.
Up: GeoPandas vs PyShp for Field Operations