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:
- GeoPackage β the sensible default for interchange: one file, many layers, UTF-8, real types
- GeoParquet β the default inside a pipeline: smallest, fastest, preserves pandas dtypes
- GeoJSON β for the web and APIs, WGS84 only, expect it to be large
- FlatGeobuf β streaming and cloud-native reads with a built-in spatial index
- 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
Step-by-step solution
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_2026becomespopulatio, and a second long name becomespopulati_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
.shpand per.dbf - encoding is a sidecar β a missing
.cpgmeans 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.
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_2025andpopulation_2026both becomepopulatio, 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
crsmember. 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.
Internal links
- How to Read and Write GeoPackage Files in Python
- How to Batch Convert Shapefiles to GeoPackage in Python
- How to Reduce GIS File Size in Python Without Wrecking the Data
- Why Are My Shapefile Column Names Truncated? How to Fix It
- GeoPandas to_file() Fails on a Column Type: How to Fix It
- WKT, WKB and GeoJSON: How Geometry Is Actually Stored
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.