This guide is part of Choosing a Vector Output Format for Spatial Pipelines, within the broader Automated Vector & Raster Cleaning Workflows reference.

GeoParquet vs FlatGeobuf vs Shapefile for Pipeline Outputs

Three formats, one layer, three different jobs. The choice is decided by what reads the file next, and the differences are large enough that guessing costs real money at scale.

The Comparison That Matters

GeoParquet FlatGeobuf Shapefile
Files per layer 1 1 4+
Size, 2.4 M polygons 310 MB 940 MB 1.9 GB
Size cap none none 2 GB per file
Field name length unlimited unlimited 10 characters
True nulls yes yes no
Column pruning yes no no
Spatial index row-group statistics packed R-tree none (.sbn is optional)
Streaming read by row group by feature whole file
CRS storage in file metadata in file header .prj sidecar
Desktop GIS support good, recent versions good, recent versions universal

Where Each One Wins

One format per consumer, not one format per pipeline Three consumers with the format each one is best served by. An analytical query engine reading a few columns of many rows is served by GeoParquet. A browser rendering a large layer progressively is served by FlatGeobuf's packed index. A mandated external handover is served by Shapefile, with its three structural losses noted. GeoParquet reader: a query engine wins on: size, column pruning partitioned datasets the default for anything the pipeline itself reads back FlatGeobuf reader: a browser or viewer wins on: packed R-tree feature-by-feature streaming a bbox read fetches only the features in the window Shapefile reader: a mandate loses: field names > 10 chars loses: nulls, 2 GB ceiling export alongside a lossless primary, never instead of one

Writing All Three From One Frame

from __future__ import annotations

import logging
from pathlib import Path

import geopandas as gpd

logger = logging.getLogger(__name__)


def write_outputs(
    gdf: gpd.GeoDataFrame,
    stem: Path,
    analytical: bool = True,
    streaming: bool = False,
    legacy: bool = False,
) -> dict[str, Path]:
    """Write the layer once per consumer that genuinely needs its own format."""
    if gdf.crs is None:
        raise ValueError("refusing to write a layer with no CRS")

    written: dict[str, Path] = {}

    if analytical:
        path = stem.with_suffix(".parquet")
        gdf.to_parquet(path, compression="zstd", write_covering_bbox=True, index=False)
        written["geoparquet"] = path

    if streaming:
        path = stem.with_suffix(".fgb")
        gdf.to_file(path, driver="FlatGeobuf", SPATIAL_INDEX="YES")
        written["flatgeobuf"] = path

    if legacy:
        path = stem.with_suffix(".shp")
        truncated = {c: c[:10] for c in gdf.columns if len(c) > 10 and c != gdf.geometry.name}
        if truncated:
            logger.warning("shapefile export truncates %d field name(s): %s",
                           len(truncated), truncated)
        gdf.to_file(path, driver="ESRI Shapefile", encoding="utf-8")
        written["shapefile"] = path

    logger.info("wrote %s", {k: v.name for k, v in written.items()})
    return written

Key Implementation Notes

  • write_covering_bbox=True writes the bounds columns. Without them, a reader cannot prune row groups on a spatial predicate, which is most of GeoParquet’s advantage — the mechanism described in reading GeoParquet from S3 with pyarrow filters.
  • SPATIAL_INDEX="YES" is what makes FlatGeobuf worth choosing. Without the packed R-tree it is a compact serialisation with no read advantage over anything else.
  • Shapefile truncation is logged, not silent. The names that collide after truncation are the ones that will confuse whoever receives the file, and telling them is the minimum courtesy.
  • The CRS check runs first. Two of the three formats record the CRS internally and one relies on a sidecar; a layer written without one is unusable in all three.
  • Each format is optional. Writing all three by default triples the storage for consumers that may not exist; the flags make the decision explicit per dataset.
  • UTF-8 is stated for the shapefile. GDAL writes a .cpg accordingly, which is the only thing standing between a receiving system and mojibake.
Bytes read for a three-column analytical query The same query — three of eighteen attribute columns across all 2.4 million features — against the same layer in three formats. GeoParquet reads only the requested column chunks, transferring about forty-one megabytes. FlatGeobuf and Shapefile are both row-oriented, so every attribute of every feature is read, transferring the whole file in each case. read 3 of 18 columns · 2.4 M features GeoParquet 41 MB FlatGeobuf 940 MB — row-oriented, all columns Shapefile 1 900 MB — row-oriented, plus sidecars For a bbox query rather than a column query the ordering changes: FlatGeobuf's index makes it competitive with GeoParquet and far ahead of Shapefile.

What Each Format Loses

GeoParquet loses nothing structural, and its one practical cost is tooling age: a consumer on an older GDAL or an old desktop GIS may not read it. That gap has closed substantially but is worth checking before making it the only export.

The cost side of each format Three formats with what each gives up. GeoParquet's only real loss is reach on older desktop stacks. FlatGeobuf is row-oriented so it cannot prune columns. Shapefile loses field names beyond ten characters, cannot represent nulls, caps at two gigabytes per file and cannot mix geometry types in one layer. format what it gives up GeoParquet reach on older GDAL and desktop stacks — and closing FlatGeobuf column pruning — row-oriented, so a 3-column query reads 18 Shapefile field names, nulls, 2 GB ceiling, one geometry type Only the third row loses information that cannot be recovered from the file itself.

FlatGeobuf loses column pruning — it is row-oriented, so a query touching three columns reads all eighteen. It also has a smaller ecosystem than Parquet outside the geospatial world, which matters if a data team wants to read the layer with general-purpose tooling.

Shapefile loses the most and is the most widely readable. Ten-character field names collide, as described in standardizing column names across multiple shapefiles; nulls become empty strings or zeros; the 2 GB ceiling is per file and reached sooner than expected on dense geometry; and mixed geometry types cannot coexist in one file at all.

Troubleshooting Format Choices

Symptom Likely cause Fix
Spatial filters read the whole file Bounds columns or index not written write_covering_bbox=True; SPATIAL_INDEX="YES"
Field names collide in an export Shapefile ten-character truncation Rename before export; log the mapping
Nulls became zeros DBF has no null representation Export a lossless format alongside
Write fails near 2 GB Shapefile size ceiling Split by region, or stop using Shapefile
Consumer cannot open the file GeoParquet newer than their GDAL Check the consumer’s stack before choosing
CRS missing after handover .prj sidecar not transferred Ship the whole file set, or use a single-file format

Integration Note

Format is a publication decision, made per consumer rather than per pipeline. The curated copy the pipeline reads back should be GeoParquet, partitioned as described in partitioning GeoParquet by region and date; anything else is an export derived from it, regenerated rather than maintained. That separation is what keeps a mandated Shapefile handover from becoming the pipeline’s own storage format.

Where GeoPackage Fits

The three formats above cover machine consumers; a fourth covers the human one. GeoPackage is a single SQLite file holding one or many layers, with real nulls, unrestricted field names, an optional spatial index and universal support in desktop GIS. It sits between Shapefile and the modern formats: larger than GeoParquet, structurally lossless, and openable by double-clicking.

It is the right answer for a handover to someone who will open the file rather than query it, and for the case where several related layers should travel together — a parcels layer, its zoning lookup and a boundary, in one file that cannot be separated in transit.

It is the wrong answer as a pipeline’s internal storage. SQLite is a single-writer database, so concurrent partition writes contend, and there is no column pruning to reward a query that touches three fields of forty.

The practical rule across all four formats is that a pipeline should have exactly one lossless internal format and as many exports as it has kinds of consumer. Exports are regenerated from the internal copy and never edited, which keeps the number of things that can disagree at one.

Benchmark Your Own Layer

The figures on this page come from one 2.4 million polygon layer with eighteen attributes, and the ratios move with geometry complexity and attribute width. A layer of simple points with two columns compresses very differently from one of dense coastline polygons.

Benchmarking your own is half an hour of work: write the same frame in each format, record the file sizes, then time a column query, a bbox query and a full read against each. Those five numbers decide the format choice more reliably than any general recommendation, and they are worth repeating when the layer’s shape changes materially.

Migrating an Existing Archive

Most teams arrive at this decision with a directory of Shapefiles already in production, and the migration is more about bookkeeping than about conversion.

Convert in place under a new prefix rather than replacing files, so the old copies remain readable while consumers move. Record, per converted layer, the source path, the feature count, the CRS and a checksum — the manifest described in converting Shapefiles to GeoParquet with GeoPandas — which makes the conversion resumable and its completeness checkable.

Two things need a decision rather than a default. Truncated field names should be restored from the original header if one survives, and the mapping recorded either way; and layers whose CRS lived only in a .prj need that CRS asserted rather than assumed, because a missing sidecar produces a converted file that is silently unreferenced.

Announce a date after which the old copies stop being updated, and keep them read-only rather than deleting them. A migration where the old path simply stops changing is far easier for consumers to notice than one where it disappears.