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:

  1. pyogrio reads whole columns into NumPy arrays in C β€” fast, and the default since GeoPandas 1.0
  2. Fiona yields one Python dict per feature β€” slower, but streamable and flexible
  3. pyogrio supports columns=, where=, bbox=, skip_features=, max_features=, sql=
  4. Fiona is the better fit when you need to stream millions of features without materialising a frame
  5. 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

Panels contrasting fiona's per-feature Python dicts with pyogrio's vectorised column transfer.
The same driver, the same bytes β€” the cost is in how they cross into Python.

Step-by-step solution

Grid comparing fiona and pyogrio across speed, arguments, streaming, and write support.
Where the two engines differ β€” and the four arguments only one of them accepts.

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.

Bar chart of read time by engine and by narrowing options for the same dataset.
Engine choice is worth several times; narrowing the read is worth several more.

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 raise TypeError with Fiona.
  • skip_features is cheap only on indexed formats: GeoPackage and FlatGeobuf seek; GeoJSON and CSV must scan from the start.
  • use_arrow=True needs 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.

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.