GIS Vector File Formats Compared: Shapefile, GeoPackage, GeoJSON, Parquet

Problem statement

The same 800,000 buildings, written five ways:

buildings.shp  + .shx + .dbf + .prj + .cpg      1.4 GB    field names truncated to 10 chars
buildings.gpkg                                  420 MB    one file, many layers, UTF-8
buildings.geojson                               2.2 GB    text, WGS84 only
buildings.fgb                                   310 MB    binary, streamable, indexed
buildings.parquet                                96 MB    columnar, keeps pandas dtypes

The choice looks like a matter of taste until it starts costing you: a column silently renamed to populatio, a client who cannot open Parquet, a web map that will not load a 2 GB file, a datetime that lost its time, an encoding that mangled every accented place name.

Each format encodes the same Simple Features model. They differ in what else they can carry β€” field names, types, multiple layers, an index, a CRS definition β€” and those differences decide which one is right for a given job.

Quick answer

Pick by the constraint that actually binds:

  1. GeoPackage β€” the sensible default for interchange: one file, many layers, UTF-8, real types
  2. GeoParquet β€” the default inside a pipeline: smallest, fastest, preserves pandas dtypes
  3. GeoJSON β€” for the web and APIs, WGS84 only, expect it to be large
  4. FlatGeobuf β€” streaming and cloud-native reads with a built-in spatial index
  5. Shapefile β€” only when a recipient requires it; expect truncation and know the limits
import geopandas as gpd

gdf = gpd.read_file("data/raw/buildings.gpkg")

gdf.to_file("out/buildings.gpkg", layer="buildings", driver="GPKG")     # interchange
gdf.to_parquet("out/buildings.parquet", compression="zstd")             # pipeline
gdf.to_crs(4326).to_file("out/buildings.geojson", driver="GeoJSON",
                          COORDINATE_PRECISION=6)                        # web
gdf.to_file("out/buildings.fgb", driver="FlatGeobuf")                    # streaming

The one habit worth adopting: keep a full-fidelity master in GeoPackage or Parquet, and derive every other product from it rather than converting between products.

The five formats at a glance

Grid comparing shapefile, GeoPackage, GeoJSON, FlatGeobuf and GeoParquet across capabilities.
What each format can carry β€” the shapefile row is where most surprises come from.

Step-by-step solution

Decision tree choosing a vector format based on who consumes the data.
The consumer decides the format β€” not the analysis that produced it.

Shapefile: what it costs you

It is thirty-five years old, it is everywhere, and every one of its limits is a real-world bug:

import geopandas as gpd

gdf = gpd.read_file("data/raw/parcels.gpkg")
gdf["population_density_2026"] = 1.0
gdf["surveyed_at"] = gpd.pd.Timestamp.now(tz="UTC")
gdf["is_reviewed"] = True

gdf.to_file("out/parcels.shp", driver="ESRI Shapefile")

back = gpd.read_file("out/parcels.shp")
print(sorted(set(gdf.columns) - set(back.columns)))   # renamed, not missing
print(back.dtypes)

The list of limits worth memorising:

  • field names: 10 bytes β€” population_density_2026 becomes populatio, and a second long name becomes populati_1
  • text fields: 254 characters β€” longer values are truncated silently
  • no boolean type β€” becomes an integer or a one-character string
  • no datetime β€” dates survive, times are dropped
  • one geometry type per file β€” polygons and lines need separate files
  • 2 GB limit per .shp and per .dbf
  • encoding is a sidecar β€” a missing .cpg means guessing
  • five to seven files that must travel together

Use it when a recipient's software requires it, rename the fields yourself before writing, and keep the real version elsewhere.

GeoPackage: the sensible default

import geopandas as gpd

parcels = gpd.read_file("data/clean/parcels.gpkg")
roads = gpd.read_file("data/clean/roads.gpkg")

# many layers, one file
parcels.to_file("out/atlas.gpkg", layer="parcels", driver="GPKG")
roads.to_file("out/atlas.gpkg", layer="roads", driver="GPKG")

import pyogrio
print(pyogrio.list_layers("out/atlas.gpkg"))

GeoPackage is an SQLite database with an OGC-standardised schema. That gives you a single file, UTF-8 throughout, unlimited field-name and text lengths, real date/datetime types, multiple layers of different geometry types, a spatial index, and room for rasters and metadata tables. It is readable by QGIS, ArcGIS, GDAL and anything else built in the last decade.

Its weaknesses are the SQLite ones: concurrent writers corrupt it, and it is a poor choice on a network share where locking is unreliable.

GeoJSON: for the web, with rules

import geopandas as gpd

gdf = gpd.read_file("data/clean/parcels.gpkg")

# RFC 7946: coordinates must be WGS84
gdf.to_crs(4326).to_file(
    "out/parcels.geojson", driver="GeoJSON",
    COORDINATE_PRECISION=6,      # ~0.1 m; the default of 7 is often more than you need
    RFC7946="YES",
)

It is plain JSON, so every language and every browser can read it without a GIS library. It carries attributes with the geometry and allows mixed geometry types in one collection. The costs are size β€” text with repeated keys, typically 3–5Γ— a binary format β€” and the WGS84 requirement, which means a projected layer must be reprojected before writing or consumers will place it wrongly.

For line-delimited streaming, GeoJSONSeq (.geojsonl) writes one feature per line, which is far friendlier to large datasets and to grep.

FlatGeobuf: streaming and cloud-native

import geopandas as gpd

gdf.to_file("out/buildings.fgb", driver="FlatGeobuf")

# a bbox read fetches only the relevant part of the file, over HTTP if needed
subset = gpd.read_file(
    "https://example.org/data/buildings.fgb",
    bbox=(325000, 673000, 335000, 683000),
)
print(len(subset))

FlatGeobuf is a flat binary buffer of features with an optional packed Hilbert R-tree at the front. That layout means a reader can fetch the index, work out which byte ranges it needs, and request only those β€” so a bbox query against a file on object storage transfers megabytes rather than gigabytes. It is the best choice for serving large vector data without a tile server or database.

GeoParquet: the pipeline format

import geopandas as gpd

gdf.to_parquet("out/buildings.parquet", compression="zstd")

# read only the columns you need β€” columnar storage makes this genuinely cheaper
slim = gpd.read_parquet("out/buildings.parquet", columns=["id", "class", "geometry"])

# a folder of parts reads as one dataset
gpd.read_parquet("out/buildings/")

Parquet stores columns rather than rows, compresses each column with a codec suited to its type, and records the schema β€” including pandas extension dtypes and timezone-aware timestamps β€” in the file. GeoParquet adds a standard metadata block describing the geometry column and its CRS. The result is typically the smallest file, the fastest read, and the only format that round-trips a GeoDataFrame exactly.

The limitation is ecosystem: QGIS reads GeoParquet only in recent versions with a suitable GDAL, and many desktop tools do not. It is a pipeline and analytics format, not a delivery format.

Measure it on your own data

from pathlib import Path
import time
import geopandas as gpd

def compare_formats(gdf, out=Path("build/formats")):
    out.mkdir(parents=True, exist_ok=True)
    results = []

    writers = {
        "shapefile": lambda p: gdf.to_file(p / "d.shp", driver="ESRI Shapefile"),
        "geopackage": lambda p: gdf.to_file(p / "d.gpkg", driver="GPKG"),
        "geojson": lambda p: gdf.to_crs(4326).to_file(p / "d.geojson", driver="GeoJSON"),
        "flatgeobuf": lambda p: gdf.to_file(p / "d.fgb", driver="FlatGeobuf"),
        "parquet": lambda p: gdf.to_parquet(p / "d.parquet", compression="zstd"),
    }
    readers = {
        "shapefile": lambda p: gpd.read_file(p / "d.shp"),
        "geopackage": lambda p: gpd.read_file(p / "d.gpkg"),
        "geojson": lambda p: gpd.read_file(p / "d.geojson"),
        "flatgeobuf": lambda p: gpd.read_file(p / "d.fgb"),
        "parquet": lambda p: gpd.read_parquet(p / "d.parquet"),
    }

    for name, write in writers.items():
        folder = out / name
        folder.mkdir(exist_ok=True)
        t0 = time.perf_counter(); write(folder); write_s = time.perf_counter() - t0
        size = sum(f.stat().st_size for f in folder.iterdir())
        t0 = time.perf_counter(); back = readers[name](folder); read_s = time.perf_counter() - t0
        results.append({
            "format": name, "MB": round(size / 1e6, 1),
            "write_s": round(write_s, 2), "read_s": round(read_s, 2),
            "columns_kept": len(back.columns) == len(gdf.columns),
        })

    return gpd.pd.DataFrame(results).sort_values("MB")

print(compare_formats(gpd.read_file("data/clean/parcels.gpkg")).to_string(index=False))

Numbers from your own data beat any table in an article β€” geometry complexity and attribute width change the ratios substantially.

Converting safely

import geopandas as gpd

def convert(src, dest, target_crs=None, precision=None):
    gdf = gpd.read_file(src)
    if target_crs:
        gdf = gdf.to_crs(target_crs)

    suffix = str(dest).lower()
    if suffix.endswith(".parquet"):
        gdf.to_parquet(dest, compression="zstd")
    elif suffix.endswith(".geojson"):
        opts = {"COORDINATE_PRECISION": precision} if precision else {}
        gdf.to_crs(4326).to_file(dest, driver="GeoJSON", **opts)
    elif suffix.endswith(".shp"):
        export = gdf.copy()
        export.columns = [c[:10] for c in export.columns]     # choose the truncation yourself
        export.to_file(dest, driver="ESRI Shapefile", encoding="utf-8")
    else:
        gdf.to_file(dest, driver="GPKG")

    back = gpd.read_parquet(dest) if suffix.endswith(".parquet") else gpd.read_file(dest)
    print(f"{src} β†’ {dest}: {len(gdf)} β†’ {len(back)} features, "
          f"{len(gdf.columns)} β†’ {len(back.columns)} columns")

Reading back after every conversion is the only way to see what a driver silently changed.

Code examples

Example 1: what survives a round trip

import geopandas as gpd
import pandas as pd
from shapely.geometry import Point

gdf = gpd.GeoDataFrame({
    "very_long_column_name": [1, 2],
    "text": ["a" * 300, "MΓΌnchen"],
    "flag": pd.array([True, False], dtype="boolean"),
    "when": pd.to_datetime(["2026-08-11 14:30", "2026-08-12 09:15"], utc=True),
    "count": pd.array([1, None], dtype="Int64"),
}, geometry=[Point(0, 0), Point(1, 1)], crs=4326)

for name, path, writer, reader in [
    ("shapefile", "build/t.shp", lambda p: gdf.to_file(p), gpd.read_file),
    ("geopackage", "build/t.gpkg", lambda p: gdf.to_file(p, driver="GPKG"), gpd.read_file),
    ("geojson", "build/t.geojson", lambda p: gdf.to_file(p, driver="GeoJSON"), gpd.read_file),
    ("parquet", "build/t.parquet", lambda p: gdf.to_parquet(p), gpd.read_parquet),
]:
    try:
        writer(path)
        back = reader(path)
        print(f"\n{name}")
        print("  columns :", list(back.columns))
        print("  text len:", back["text"].str.len().max() if "text" in back else "β€”")
        print("  dtypes  :", {c: str(t) for c, t in back.dtypes.items() if c != "geometry"})
    except Exception as exc:
        print(f"\n{name}: FAILED β€” {type(exc).__name__}: {exc}")

This one script teaches the whole comparison faster than any documentation: watch the column names shorten, the boolean become an integer, the timezone vanish and the 300-character string get cut to 254.

Example 2: a multi-layer GeoPackage as a deliverable

import geopandas as gpd
from pathlib import Path

dest = Path("out/delivery.gpkg")
dest.unlink(missing_ok=True)

LAYERS = {
    "parcels": "data/clean/parcels.gpkg",
    "roads": "data/clean/roads.gpkg",
    "boundaries": "data/ref/wards.gpkg",
}
for layer, src in LAYERS.items():
    gpd.read_file(src).to_crs(27700).to_file(dest, layer=layer, driver="GPKG")

import pyogrio
for name, geom_type in pyogrio.list_layers(dest):
    info = pyogrio.read_info(dest, layer=name)
    print(f"{name:12} {geom_type:14} {info['features']:>8,} features")

Example 3: partial reads, which only some formats support

import geopandas as gpd

BBOX = (325000, 673000, 335000, 683000)

# GeoPackage: uses its R-tree index
gpkg = gpd.read_file("data/clean/parcels.gpkg", bbox=BBOX)

# FlatGeobuf: index at the front, so this works over HTTP too
fgb = gpd.read_file("data/clean/parcels.fgb", bbox=BBOX)

# Parquet: columnar, so column selection is cheap; row filtering needs a predicate
parquet = gpd.read_parquet("data/clean/parcels.parquet", columns=["id", "geometry"])

# GeoJSON: no index β€” the whole file is parsed regardless
geojson = gpd.read_file("data/clean/parcels.geojson", bbox=BBOX)

for name, layer in [("gpkg", gpkg), ("fgb", fgb), ("parquet", parquet), ("geojson", geojson)]:
    print(f"{name:8} {len(layer):>8,} features")

Example 4: choose the format from the audience

AUDIENCE = {
    "analyst_python":  {"format": "parquet",   "crs": None,  "note": "keeps dtypes exactly"},
    "gis_desktop":     {"format": "gpkg",      "crs": 27700, "note": "opens everywhere"},
    "web_map":         {"format": "geojson",   "crs": 4326,  "note": "simplify first"},
    "large_web_layer": {"format": "fgb",       "crs": 4326,  "note": "bbox reads over HTTP"},
    "legacy_client":   {"format": "shp",       "crs": 27700, "note": "rename fields first"},
}

for who, spec in AUDIENCE.items():
    print(f"{who:16} β†’ .{spec['format']:8} {spec['note']}")

Explanation

Every format here stores the same Simple Features geometry β€” points, lines, polygons and their multi-part forms β€” usually as WKB internally. The differences are entirely in the container: what metadata it can hold, how attributes are typed, whether there is an index, and how the bytes are laid out.

Bar chart of file size by format for the same dataset.
The same 800,000 buildings β€” the spread is 20Γ— between the largest and smallest.

Shapefile's limits are not arbitrary; they are the dBase III table format from 1983 showing through. Field names live in a fixed 11-byte header slot, text fields in fixed-width character columns, and there is no boolean or timestamp type because dBase had none. The multi-file structure exists because each component β€” geometry, index, attributes, projection β€” was a separate concern in 1990s software. None of that is fixable within the format, which is why the OGC standardised GeoPackage as a replacement.

GeoPackage takes the opposite approach: put everything in SQLite and standardise the schema. Attributes become SQL columns with SQL types, so names and lengths are unconstrained and dates are real dates. Multiple layers are multiple tables. The spatial index is an R-tree virtual table. The CRS lives in gpkg_spatial_ref_sys. Everything a GIS needs is expressible, and the file is a database you can query directly if you want to.

Parquet comes from the analytics world rather than the GIS one, and its advantage is columnar storage: values of one column sit together, so they compress extremely well and a reader can skip whole columns without parsing them. GeoParquet's contribution is a metadata convention describing which column is geometry and what its CRS is. For a pipeline that reads three of forty columns, this is transformative; for a colleague opening a file in QGIS 3.22, it is unusable.

FlatGeobuf occupies the remaining niche: a binary format designed for streaming, with a spatial index at the front so a client can range-request exactly the bytes covering a bounding box. That makes a static file on object storage behave like a lightweight feature service, which is why it has become popular for publishing large open datasets without infrastructure.

The practical conclusion is that there is no best format, only a best pairing of format and consumer β€” and that a pipeline should keep one high-fidelity master and derive products from it. Chains of conversions between lossy formats are where field names, precision and types quietly disappear.

Edge cases or notes

  • Shapefile field names collide after truncation: population_2025 and population_2026 both become populatio, and the driver appends a counter. Rename deliberately.
  • GeoPackage and concurrency: SQLite allows one writer. Never write to one GeoPackage from parallel processes.
  • GeoJSON must be WGS84: RFC 7946 removed the crs member. Reproject before writing.
  • .geojsonl / GeoJSONSeq: One feature per line, streamable, far friendlier for very large exports than a single JSON document.
  • Parquet needs a recent GDAL for QGIS: Check the recipient's version before delivering it.
  • FlatGeobuf is append-friendly, not edit-friendly: It is designed for write-once, read-many.
  • KML, GML, MapInfo TAB: Still common in some sectors. GDAL reads them all β€” convert on ingest rather than working in them.

FAQ

Which vector format should I use by default?

GeoPackage for anything you hand to another person, GeoParquet for anything that stays inside a Python pipeline. Both are lossless for attributes, which shapefile is not.

Is the shapefile really that bad?

It works, and it is universal. The problems are specific and predictable: 10-byte field names, 254-character text, no booleans or times, one geometry type per file, and multiple files that must stay together.

Why is my GeoJSON so large?

It is text: coordinates as decimal strings and key names repeated per feature. Reduce COORDINATE_PRECISION, simplify the geometry, and rely on gzip in transport β€” GeoJSON compresses very well.

When should I use FlatGeobuf?

When clients need to read part of a large dataset directly from static hosting. Its front-loaded spatial index lets a bbox query fetch only the relevant byte ranges, even over HTTP.

Can QGIS open GeoParquet?

Recent versions with a GDAL built for Parquet can. Check before delivering; GeoPackage remains the safe interchange choice.

How do I keep timezone-aware timestamps?

Use GeoParquet, which preserves the pandas dtype exactly. GeoPackage stores a datetime but may normalise the offset; shapefile drops the time entirely.

Does converting between formats lose data?

It can. Every conversion is limited by the target format, so a chain through shapefile permanently loses long field names and text. Keep a master in a high-fidelity format and derive from it.