Fiona vs pyogrio: How GeoPandas Reads and Writes Files
Problem statement
The same call, two engines, very different behaviour:
>>> %timeit gpd.read_file("data/raw/parcels.gpkg", engine="fiona")
8.42 s Β± 141 ms per loop
>>> %timeit gpd.read_file("data/raw/parcels.gpkg", engine="pyogrio")
0.71 s Β± 22 ms per loop
And arguments that work with one and not the other:
>>> gpd.read_file(path, engine="pyogrio", columns=["id", "class"]) # fine
>>> gpd.read_file(path, engine="fiona", columns=["id", "class"])
TypeError: read_file() got an unexpected keyword argument 'columns'
GeoPandas has two I/O backends. Both call GDAL; they differ in how, and that difference shows up as a factor of ten in speed, a different argument list, and different behaviour on some edge cases. Since GeoPandas 1.0 the default is pyogrio, which means code written against Fiona's behaviour can change under you on upgrade.
Quick answer
Both wrap GDAL; the difference is per-feature Python versus vectorised bulk transfer:
- pyogrio reads whole columns into NumPy arrays in C β fast, and the default since GeoPandas 1.0
- Fiona yields one Python dict per feature β slower, but streamable and flexible
- pyogrio supports
columns=,where=,bbox=,skip_features=,max_features=,sql= - Fiona is the better fit when you need to stream millions of features without materialising a frame
- You can force either engine per call, or globally
import geopandas as gpd
# per call
gdf = gpd.read_file("data/raw/parcels.gpkg", engine="pyogrio")
gdf = gpd.read_file("data/raw/parcels.gpkg", engine="fiona")
# globally, e.g. while migrating an old codebase
gpd.options.io_engine = "pyogrio"
# what you actually have
import pyogrio, fiona
print("pyogrio", pyogrio.__version__, "GDAL", pyogrio.__gdal_version_string__)
print("fiona ", fiona.__version__, "GDAL", fiona.__gdal_version__)
The practical rule: use pyogrio for everything unless you are streaming feature-by-feature, in which case Fiona's iterator model is the right tool.
Two paths to the same GDAL
Step-by-step solution
Why pyogrio is faster
Fiona's model is a Python iterator of GeoJSON-like dicts. Reading a million features means constructing a million dicts, each with a nested coordinate list, then building a GeoDataFrame from them. Every one of those objects is allocated, populated and garbage-collected by CPython.
import fiona
with fiona.open("data/raw/parcels.gpkg") as src:
feature = next(iter(src))
print(type(feature))
print(feature["properties"])
print(feature["geometry"]["type"])
pyogrio asks GDAL for whole columns and copies them into NumPy arrays in one C-level pass, converting geometry to WKB in bulk and letting Shapely 2 build the geometry array vectorised.
import pyogrio
# the low-level view: arrays, not features
meta, geometry, fields = pyogrio.raw.read("data/raw/parcels.gpkg")
print(type(geometry), geometry.dtype) # ndarray of WKB
print([f.dtype for f in fields][:4])
That is the whole story: one bulk copy instead of a million small ones, which is where the 5β20Γ typically comes from.
The arguments only pyogrio has
import geopandas as gpd
# only the columns you need β the rest never leave GDAL
gdf = gpd.read_file(path, columns=["id", "class", "geometry"])
# attribute filter, evaluated by the driver
gdf = gpd.read_file(path, where="class = 'residential' AND area_m2 > 500")
# a row window, for chunked processing
gdf = gpd.read_file(path, skip_features=100_000, max_features=50_000)
# full SQL, for formats that support it
gdf = gpd.read_file(path, sql="SELECT id, geom FROM parcels WHERE class = 'commercial'",
sql_dialect="SQLITE")
# geometry-free read, when you only want the attribute table
df = gpd.read_file(path, ignore_geometry=True)
bbox= and mask= work with both engines, but pyogrio's implementation uses the format's spatial index where one exists.
What Fiona still does better
Streaming. Fiona hands you features one at a time and never builds a frame, so memory stays flat regardless of file size β and it works on formats where row offsets are expensive.
import fiona
with fiona.open("data/raw/huge.geojson") as src:
print(src.crs, src.schema["geometry"], len(src))
total = 0
for feature in src: # constant memory
total += feature["properties"].get("area_m2") or 0
print(f"{total:,.0f} mΒ² without loading anything")
It also exposes the schema as a first-class object, which is handy when you are writing a format converter that must preserve field types exactly:
with fiona.open("data/raw/parcels.gpkg") as src:
schema = src.schema
print(schema["properties"]) # {'id': 'int', 'class': 'str:80', 'area_m2': 'float'}
with fiona.open("out/copy.gpkg", "w", driver="GPKG",
crs=src.crs, schema=schema) as dst:
dst.writerecords(src)
Behaviour differences that bite
import geopandas as gpd
a = gpd.read_file(path, engine="fiona")
b = gpd.read_file(path, engine="pyogrio")
print(a.dtypes.equals(b.dtypes))
print(set(a.columns) ^ set(b.columns))
print(a.crs == b.crs)
The ones worth knowing:
- dtypes β pyogrio maps GDAL types to pandas more aggressively, and can return nullable dtypes with
use_arrow=True - field order and the FID β pyogrio can return the feature id as a column with
fid_as_index=True - null geometry β both produce
None, but empty vs null handling has differed across versions - datetime β pyogrio parses to
datetime64; Fiona returns strings for some drivers - encoding β Fiona takes
encoding=; pyogrio also does, but resolves the driver default differently
Any pipeline whose output must be byte-stable should pin both the engine and the versions.
Writing
import geopandas as gpd
gdf.to_file("out/parcels.gpkg", driver="GPKG", engine="pyogrio")
gdf.to_file("out/parcels.gpkg", driver="GPKG", engine="fiona")
# pyogrio exposes driver creation options directly
gdf.to_file("out/parcels.shp", driver="ESRI Shapefile", engine="pyogrio",
encoding="utf-8", promote_to_multi=True)
# append to an existing layer
gdf.to_file("out/parcels.gpkg", layer="parcels", driver="GPKG", mode="a")
promote_to_multi=True is a pyogrio convenience that solves the mixed Polygon/MultiPolygon write problem in one argument.
Arrow: the next step up
import geopandas as gpd
gdf = gpd.read_file(path, engine="pyogrio", use_arrow=True)
print(gdf.dtypes) # nullable, Arrow-backed dtypes
With use_arrow=True, pyogrio uses GDAL's Arrow stream interface, avoiding another copy and giving you nullable dtypes that survive a round trip. It requires pyarrow and a recent GDAL, and it is the fastest path available today.
Measure it on your data
import time
import geopandas as gpd
def bench(path, repeats=3):
for engine in ("fiona", "pyogrio"):
times = []
for _ in range(repeats):
t0 = time.perf_counter()
gdf = gpd.read_file(path, engine=engine)
times.append(time.perf_counter() - t0)
print(f"{engine:8} {min(times):6.2f} s {len(gdf):>9,} features")
t0 = time.perf_counter()
slim = gpd.read_file(path, engine="pyogrio", columns=["id", "geometry"])
print(f"{'columns=':8} {time.perf_counter() - t0:6.2f} s {len(slim):>9,} features")
bench("data/raw/parcels.gpkg")
The columns= line often beats the full pyogrio read by another factor of two β narrowing the read is usually worth more than the engine choice.
Code examples
Example 1: an engine-aware reader
import geopandas as gpd
def read_vector(path, columns=None, where=None, bbox=None, prefer="pyogrio"):
"""Use pyogrio's pushdown features when available, fall back cleanly."""
kwargs = {"bbox": bbox} if bbox else {}
try:
return gpd.read_file(
path, engine=prefer,
**({"columns": columns} if columns else {}),
**({"where": where} if where else {}),
**kwargs,
)
except (ImportError, TypeError, ValueError):
gdf = gpd.read_file(path, engine="fiona", **kwargs)
if where:
gdf = gdf.query(where.replace("=", "==").replace("AND", "and"))
return gdf[columns] if columns else gdf
Example 2: stream a file too large to load
import fiona
from shapely.geometry import shape
def stream_stats(path, layer=None):
total_area = 0.0
by_class = {}
with fiona.open(path, layer=layer) as src:
crs, n = src.crs, len(src)
for i, feature in enumerate(src, start=1):
geom = shape(feature["geometry"]) if feature["geometry"] else None
if geom is not None:
total_area += geom.area
cls = (feature["properties"] or {}).get("class", "unknown")
by_class[cls] = by_class.get(cls, 0) + 1
if i % 250_000 == 0:
print(f" {i:,}/{n:,}", flush=True)
return {"crs": str(crs), "features": n,
"total_area": round(total_area, 2), "by_class": by_class}
Memory stays flat at a few megabytes no matter how large the file is β the thing pyogrio's bulk model cannot do.
Example 3: chunked reads with pyogrio
import geopandas as gpd
import pyogrio
def chunks(path, size=100_000, columns=None):
total = pyogrio.read_info(path)["features"]
for start in range(0, total, size):
yield gpd.read_file(path, engine="pyogrio", columns=columns,
skip_features=start, max_features=size)
for i, part in enumerate(chunks("data/raw/buildings.gpkg", columns=["id", "geometry"])):
part["area_m2"] = part.to_crs(part.estimate_utm_crs()).area
part.to_parquet(f"data/out/part-{i:05d}.parquet", compression="zstd")
Example 4: pin the engine in a pipeline
import geopandas as gpd
def configure_io(engine: str = "pyogrio") -> None:
"""Fix the I/O engine so behaviour cannot change under a library upgrade."""
gpd.options.io_engine = engine
import pyogrio, fiona
versions = {
"geopandas": gpd.__version__,
"engine": engine,
"pyogrio": pyogrio.__version__,
"fiona": fiona.__version__,
"gdal": pyogrio.__gdal_version_string__,
}
print(" | ".join(f"{k}={v}" for k, v in versions.items()))
return versions
versions = configure_io("pyogrio") # record these in your run metadata
Explanation
Both libraries are thin wrappers over the same GDAL vector API, so they can read exactly the same formats and produce exactly the same geometry. What differs is the boundary between C and Python, and where that boundary sits determines everything else.
Fiona was designed around Python idioms: a dataset is a context manager, features are dicts, iteration is lazy. That is elegant and, for streaming work, exactly right β memory does not grow with file size, and you can process a 40 GB GeoJSON on a laptop. The cost is that every feature crosses the C boundary individually and becomes several Python objects, which dominates the runtime for bulk reads.
pyogrio was built for the opposite case: you want the whole thing as a DataFrame. It reads columns in bulk, hands NumPy arrays back, and lets Shapely 2's vectorised constructors turn a WKB array into a GeometryArray in one call. Because the transfer is columnar, it can also push work down into GDAL β selecting columns, filtering rows, skipping features β so the excluded data never materialises anywhere.
That pushdown is why the argument lists differ, and why the difference matters more than raw speed. where="class = 'residential'" on a 40-million-row layer is evaluated by the driver, and only matching rows are transferred. There is no Fiona equivalent short of iterating everything and filtering in Python.
GeoPandas 1.0 made pyogrio the default, which is the right choice for the common case but does mean upgrades can shift behaviour: dtypes, datetime parsing, and null handling all have small differences. For a pipeline whose output feeds something downstream, setting gpd.options.io_engine explicitly and recording the library versions in the run metadata costs nothing and removes an entire class of "it changed and nobody touched it" incidents.
Edge cases or notes
- pyogrio is the default from GeoPandas 1.0: Old code that relied on Fiona's dtype behaviour may see differences after an upgrade.
columns=,where=,sql=,skip_features=are pyogrio-only: They raiseTypeErrorwith Fiona.skip_featuresis cheap only on indexed formats: GeoPackage and FlatGeobuf seek; GeoJSON and CSV must scan from the start.use_arrow=Trueneeds pyarrow and a recent GDAL: It gives nullable dtypes and the fastest path, but changes dtypes compared with the default.- Fiona 1.9+ changed its Feature objects: They are now class instances rather than plain dicts, though still mapping-like.
- Both need the driver: Neither can read a format GDAL was not compiled with. Check
pyogrio.list_drivers(). - Writes differ subtly:
promote_to_multi, encoding defaults and layer options are not identical. Test the round trip when switching.
Internal links
- What GDAL and OGR Actually Are (and Why Everything Depends on Them)
- How to Speed Up GeoPandas: Tips for Large Datasets
- How to Process a Very Large GeoPackage in Chunks with Python
- Fiona ImportError When Using GeoPandas: How to Fix It
- GeoPandas Basics: Working with Spatial Data in Python
- Fixing Memory Errors in GeoPandas When Working with Large Files
FAQ
Which engine does GeoPandas use by default?
pyogrio, since GeoPandas 1.0. Earlier versions used Fiona. Set gpd.options.io_engine or pass engine= per call to be explicit.
Is pyogrio always faster?
For bulk reads and writes, substantially β typically 5β20Γ. Fiona wins when you want to stream features without building a frame at all, because its memory use does not grow with file size.
Why does columns= raise a TypeError?
Because the call fell back to Fiona, which has no such argument. columns=, where=, sql=, skip_features= and max_features= are pyogrio features.
Should I remove Fiona from my environment?
No. It is still the right tool for streaming, and some libraries depend on it. Keeping both installed costs nothing.
Will switching engines change my data?
Usually not the geometry, but possibly the dtypes β nullable integers, datetime parsing and null handling differ slightly. Compare dtypes after switching, and pin the engine in production.
What does use_arrow=True do?
It uses GDAL's Arrow stream interface, avoiding a conversion step and returning Arrow-backed nullable dtypes. It is the fastest option where pyarrow and a recent GDAL are available.
Which should I use for writing?
pyogrio, for the same speed reasons, plus conveniences like promote_to_multi. Fiona is preferable when you are copying a schema verbatim from one dataset to another.