Speeding Up GeoPandas I/O with pyogrio and Arrow
A gpd.read_file() call that takes two minutes on a multi-million-feature layer is usually not limited by your disk, your CPU or the driver — it is limited by how many times the Python interpreter is asked to touch a single feature. This guide is for anyone whose pipeline spends most of its wall clock in the read step: it sits under GeoPandas DataFrames Explained in Mastering Core Geospatial Python Libraries, and covers the pyogrio engine that GeoPandas 1.x defaults to, the use_arrow=True transfer path, the read-time pushdowns that skip work entirely, and a benchmark harness honest enough to publish.
Why This Approach / What Goes Wrong
Both I/O engines call the same GDAL/OGR C library, open the same file with the same driver and produce the same GeoDataFrame. The difference is entirely in the hand-off. Fiona exposes OGR as a feature iterator: every feature is materialised as a Python dict of properties plus a GeoJSON-ish geometry mapping, and GeoPandas then builds the geometry column by constructing one Shapely object per row. On a 2.4-million-feature layer that is 2.4 million interpreter round-trips before any analysis starts, and the per-feature overhead — dict allocation, attribute boxing, reference counting — dominates the actual decoding.
pyogrio removes the loop rather than optimising it. It asks OGR for the whole layer in bulk from C, fills typed NumPy arrays column by column, and converts the geometry column with a single vectorised shapely.from_wkb call over a buffer of WKB blobs. Python sees a handful of finished arrays, not N features. That is why the speed-up is roughly a constant factor across file sizes instead of something that only shows up on small files: you have deleted a per-row cost, not amortised it.
use_arrow=True shortens the hand-off one step further. GDAL 3.6 added an Arrow array stream interface, so instead of pyogrio calling OGR's per-feature field accessors in C and packing NumPy arrays itself, the driver emits Arrow record batches — typed, columnar, dictionary-encoded for repeated strings — and pyarrow hands those buffers to pandas with far less copying. The win is largest on wide attribute tables and on string-heavy layers; on a narrow layer whose cost is dominated by parsing complex polygons into Shapely, it is modest. It is a transfer-format change, not a magic flag.
The largest win, though, is not a faster read: it is a read that never happens. columns=, bbox=, mask=, where= and rows= are all evaluated by GDAL before anything reaches Python, so unwanted fields are never decoded and unwanted features are never touched. On an indexed format the bounding-box filter also lets the driver skip whole pages of the file. Choosing the container matters as much as choosing the engine — see GeoParquet vs Shapefile for storage — and if the file is larger than RAM the question stops being speed and becomes memory, which is the subject of GeoPandas vs Fiona for large files.
Prerequisites
geopandas>=1.0— pyogrio is the default I/O engine from 1.0 onward, used automatically when installedpyogrio>=0.9— the vectorised read/write engine; bundles or binds GDALpyarrow>=16— required foruse_arrow=True; GDAL must be 3.6 or newershapely>=2.0— supplies the vectorisedfrom_wkbthat builds the geometry columnfiona>=1.9— optional, needed only to benchmark the legacy engine against the new one
conda install -c conda-forge "geopandas=1.0.*" "pyogrio=0.9.*" "pyarrow=16.*" \
"shapely=2.0.*" "fiona=1.9.*"
Install the whole set from conda-forge rather than mixing wheels: pyogrio, Fiona and pyproj each bind GDAL and PROJ, and two different GDAL builds in one environment produce import-time ABI errors or, worse, a silently different driver set on each engine — which quietly invalidates any benchmark you run.
Step-by-Step Implementation
1. Confirm which engine is actually running.
Never benchmark or tune before you know what you have. GeoPandas 1.x selects pyogrio when it is importable and falls back to Fiona otherwise, so a stale environment can silently give you the slow path.
import geopandas as gpd
import pyogrio
print(gpd.options.io_engine) # None -> auto-select (pyogrio if installed)
print(pyogrio.__version__) # 0.9.0
print(pyogrio.__gdal_version_string__) # 3.9.2 (>= 3.6 required for Arrow)
gpd.options.io_engine = "pyogrio" # pin it explicitly for reproducible runs
2. Read the layer's metadata without reading the layer.
pyogrio.read_info() opens the dataset, reads the header and closes it. You get the feature count, field names, dtypes and CRS for the price of a header read — enough to plan the pushdowns in the next steps.
info = pyogrio.read_info("buildings.fgb")
print(info["features"], "features") # 2412885 features
print(info["crs"]) # EPSG:25832
print(list(info["fields"])[:5]) # ['osm_id', 'use', 'height_m', 'levels', 'source']
3. Turn on the Arrow transfer path.
use_arrow=True is accepted by gpd.read_file() and forwarded to pyogrio. Set it per call, or once for the whole process with the PYOGRIO_USE_ARROW=1 environment variable when you cannot edit every call site.
buildings = gpd.read_file("buildings.fgb", use_arrow=True)
print(type(buildings), buildings.shape) # <class 'geopandas.geodataframe.GeoDataFrame'> (2412885, 62)
4. Read only the columns you need.
A 62-column building layer read for a height histogram wastes 60 columns of decoding, allocation and memory. columns= is a pyogrio-only argument that selects fields inside the driver. When you want attributes with no geometry at all, drop to the pyogrio API directly and pass read_geometry=False — it returns a plain pandas DataFrame and skips WKB parsing entirely.
# Geometry plus two attributes, selected inside GDAL
heights = gpd.read_file(
"buildings.fgb",
columns=["use", "height_m"],
use_arrow=True,
)
# Attribute-only: no geometry column, no WKB parsing, returns a pandas DataFrame
attrs = pyogrio.read_dataframe("buildings.fgb", columns=["use", "height_m"],
read_geometry=False, use_arrow=True)
5. Push the spatial filter down — in the file's own CRS.
bbox= hands a rectangle to GDAL, which uses the format's spatial index to skip non-matching features before decoding. The trap is projection: with the pyogrio engine the bounding box must already be in the dataset's CRS — unlike the Fiona engine, pyogrio does not reproject a GeoSeries for you. Convert your area of interest explicitly against the CRS reported in step 2. Bounding-box tuples are always (minx, miny, maxx, maxy) in x/y order regardless of what axis order the EPSG authority declares, because pyproj transformations behind to_crs() are built with always_xy=True; passing latitude first silently matches nothing.
from shapely.geometry import box
# AOI drawn in lon/lat, reprojected to the file's CRS (UTM 32N here) before use
aoi = gpd.GeoSeries([box(11.52, 48.10, 11.62, 48.17)], crs="EPSG:4326")
aoi = aoi.to_crs(info["crs"])
minx, miny, maxx, maxy = aoi.total_bounds
core = gpd.read_file(
"buildings.fgb",
bbox=(minx, miny, maxx, maxy), # already in EPSG:25832
columns=["use", "height_m"],
use_arrow=True,
)
print(len(core), "of", info["features"], "features decoded")
Use mask=<geometry> instead of bbox= when the area of interest is a real polygon rather than a rectangle; the two are mutually exclusive and passing both raises. For attribute cuts, where= sends an OGR SQL predicate into the driver, and rows=slice(0, 5000) gives you a cheap sample for schema checks.
6. Write with the same engine.
to_file() routes through pyogrio too, and the write path benefits for the same reason: the whole column is handed to OGR in bulk instead of one dst.write(feature) call per row. Writes are typically two to four times faster than the Fiona path, and the gap is smaller than on reads because the driver's own encoding work — building a spatial index, compressing, fsyncing — is unchanged.
# Bulk write through pyogrio; FlatGeobuf and GeoPackage both carry a spatial index
core.to_file("munich_residential.fgb", driver="FlatGeobuf")
# Or the pyogrio API directly, for explicit layer/driver control
pyogrio.write_dataframe(core, "munich.gpkg", layer="residential", driver="GPKG")
# GeoParquet does not go through OGR at all — pandas writes it via pyarrow
core.to_parquet("munich_residential.parquet")
7. Time it, do not guess.
Measure with a warm-up run, several repeats and a median — a single cold-cache timing mostly measures your disk. Compare identical work: same file, same columns, same process.
import statistics
import time
import geopandas as gpd
def timed(fn, repeats=5):
"""Median wall-clock seconds over `repeats` runs, after one warm-up."""
fn() # warm the OS page cache
samples = []
for _ in range(repeats):
t0 = time.perf_counter()
fn()
samples.append(time.perf_counter() - t0)
return statistics.median(samples)
path = "buildings.fgb"
cases = {
"fiona": lambda: gpd.read_file(path, engine="fiona"),
"pyogrio": lambda: gpd.read_file(path, engine="pyogrio"),
"pyogrio + arrow": lambda: gpd.read_file(path, engine="pyogrio", use_arrow=True),
"arrow + 2 columns": lambda: gpd.read_file(path, engine="pyogrio", use_arrow=True,
columns=["use", "height_m"]),
}
baseline = None
for name, fn in cases.items():
elapsed = timed(fn)
baseline = baseline or elapsed
print(f"{name:<20} {elapsed:7.2f} s {baseline / elapsed:5.1f}x")
# fiona 142.10 s 1.0x
# pyogrio 23.14 s 6.1x
# pyogrio + arrow 15.82 s 9.0x
# arrow + 2 columns 5.41 s 26.3x
Four habits keep numbers like these defensible. Warm the cache before timing, or you are ranking your storage rather than the engines. Report a median of several runs, because the first read of a session also pays for GDAL driver registration and PROJ database initialisation. Time each case in the same interpreter on the same file, since a format conversion between runs changes the experiment. And resist extrapolating across shapes of data: a wide, string-heavy attribute table and a narrow layer of hundred-vertex polygons sit at opposite ends of the Arrow speed-up range, so a factor measured on one says little about the other. If a result looks implausible, print gpd.options.io_engine and pyogrio.__gdal_version_string__ inside the timed function — a silent fall back to Fiona, or an environment with two GDAL builds, explains most surprising benchmarks.
Verification
A benchmark is only meaningful if every case returned the same data. Assert parity first, then report the timings — and state the machine, the GDAL version and the cache state alongside any number you publish.
import geopandas as gpd
from geopandas.testing import assert_geodataframe_equal
ref = gpd.read_file("buildings.fgb", engine="fiona")
fast = gpd.read_file("buildings.fgb", engine="pyogrio", use_arrow=True)
assert len(ref) == len(fast), "engines disagree on feature count"
assert ref.crs == fast.crs, "CRS lost or altered on one path"
assert set(ref.columns) == set(fast.columns), "column sets differ"
# Same geometries, same attributes; dtypes may differ (Arrow gives nullable types)
assert_geodataframe_equal(ref, fast[ref.columns], check_dtype=False)
print(f"parity OK: {len(ref):,} features, {ref.crs}")
# parity OK: 2,412,885 features, EPSG:25832
Edge Cases & Debugging
use_arrow=Trueraises or is ignored. The Arrow path needspyarrowinstalled and GDAL 3.6 or newer. Checkpyogrio.__gdal_version_string__; on an older GDAL, drop the flag and rely on the default vectorised path, which is already the large win.bbox=returns nothing (or everything). With the pyogrio engine the box is interpreted in the dataset's CRS and is never reprojected for you. Readpyogrio.read_info(path)["crs"], convert your area of interest to it, and keep tuples in(minx, miny, maxx, maxy)x/y order rather than lat/lon order.TypeErrorfrom an argument pyogrio does not accept. Fiona-era arguments do not exist on pyogrio: replaceignore_fields=withcolumns=, andignore_geometry=Truewithpyogrio.read_dataframe(..., read_geometry=False).- A pushdown makes the read slower on Shapefile or GeoJSON. Neither format carries a usable spatial index by default, so
bbox=degenerates into a full scan plus a filter. Convert once to FlatGeobuf, GeoPackage or GeoParquet and the same filter becomes a page skip. - Arrow gives almost no gain. The layer is narrow and geometry-heavy, so the time is going into
from_wkb, not attribute transfer. Reduce geometry work instead: filter spatially, simplify, or move the query to a columnar engine. - Dtypes changed after switching engines. The Arrow path can return pandas nullable or
string[pyarrow]dtypes where the legacy path returnedobjectandfloat64-with-NaN. Compare withcheck_dtype=Falseand cast explicitly where downstream code depends on a dtype.
Frequently Asked Questions
Do I have to change my code to use pyogrio?
No. From GeoPandas 1.0 the engine is selected automatically and pyogrio wins whenever it is importable, so an unmodified gpd.read_file() is already on the fast path. The changes worth making are the deliberate ones: pinning gpd.options.io_engine = "pyogrio" so a benchmark cannot silently fall back, adding use_arrow=True, and passing columns= and bbox=.
Is use_arrow=True always faster?
Not always, and rarely dramatically on its own. It removes per-field conversion between GDAL and Python, so it shines on wide tables with many string columns and does little on a two-column layer of complex polygons where WKB-to-Shapely conversion dominates. Treat it as a measured optimisation on your own data, not a default assumption — the harness in step 7 answers the question in a minute.
Does pyogrio also make GeoParquet faster?
GeoParquet does not go through pyogrio at all. gdf.to_parquet() and gpd.read_parquet() are implemented on pyarrow directly, which is why they are usually the fastest option available and support their own column and row-group filtering. Compare the containers in GeoParquet vs Shapefile for storage before assuming the engine is your bottleneck.
Can pyogrio read a file bigger than RAM? No — it still materialises the full result set, so a whole-layer read of an oversized file fails the same way it always did. Pushdowns help when the result fits even though the file does not; when neither fits, you need a streaming or chunked strategy instead, which is exactly the trade-off laid out in GeoPandas vs Fiona for large files.