Choosing a Spatial File Format for a Dashboard That Has to Load Fast
Every layer in a spatial dashboard is read from somewhere, and the format it is read from decides more about the dashboard’s performance than most of the code around it. A layer stored as a shapefile and a layer stored as GeoParquet are the same features; one of them takes four seconds and 900 MB to open and the other takes 300 milliseconds and reads only the columns the map draws. Nothing in the application distinguishes them.
This page is a working reference for the five formats a Python spatial dashboard actually encounters, organised by what each one is good at rather than by chronology. The organising question throughout is not “which format is best” — none of them is — but “which of these does the thing this layer needs”, where the things layers need are: being read partially, being read by column, being read by many readers at once, and being rendered by a browser without being parsed first.
Jump to heading What actually differs between them
Four properties separate these formats, and every performance difference downstream reduces to some combination of them.
Partial reads. Can a reader fetch the features in a bounding box without reading the whole file? A format with a spatial index and range-request support can; a format without one cannot, and a dashboard that filters to a viewport still pays for the whole country.
Columnar layout. Can a reader fetch two columns without reading the other thirty? Columnar formats make column pruning free, which as choosing dtypes shows is often the largest single reduction available.
Self-describing metadata. Does the file carry its own CRS, its own schema, and its own types? A format that does removes an entire class of the failures described in CRS & coordinate systems; one that does not makes every reader guess.
Client renderability. Can a browser draw it without a parse step that blocks the main thread? This is the property that separates tile formats from data formats, and it is why the answer for a 400,000-feature layer is never “send the file”.
Jump to heading Prerequisites
geopandas>=1.0,pyarrow>=14for GeoParquet, andfiona>=1.9orpyogriofor the OGR-backed formats.rasterio>=1.3if the dashboard touches imagery, for the Cloud-Optimized GeoTIFF section.tippecanoeif you intend to bake vector tiles. It is a command-line tool rather than a Python library, and it is the standard answer.- A clear idea of which layers are reference data that changes rarely and which are result data produced per request. The right format is almost always different for the two.
Jump to heading The five formats
Jump to heading GeoJSON — the interchange format, and only that
GeoJSON is text. Every coordinate is a decimal number written out in full, every property name is repeated on every feature, and there is no index of any kind. A 400,000-feature layer is tens of megabytes before compression and takes seconds to parse in a browser, during which nothing on the page responds.
None of that makes it a bad format. It makes it an interchange format: it is readable by every tool, debuggable in a text editor, and the only thing a browser mapping library will accept without a plugin. Use it at the boundary — the response a map component consumes, the export an analyst downloads — and do not use it as storage. The single most effective thing you can do to a GeoJSON payload is reduce the coordinate precision, as converting Shapely geometries to GeoJSON covers: six decimals is finer than any screen pixel, and a float repr emits fifteen.
Jump to heading Shapefile — read it, do not write it
The shapefile persists because thirty years of public data is published in it. It is worth knowing three things about reading one. It is several files that must travel together, and the .prj holding the coordinate system is the one most often missing — which produces a frame with crs=None and every downstream predicate silently empty. Column names are limited to ten characters, so population_2021 arrives as populatio. And there is no reliable text encoding declaration, so non-ASCII names arrive mangled unless you specify one.
import geopandas as gpd
gdf = gpd.read_file("boundaries.shp", encoding="utf-8")
if gdf.crs is None:
gdf = gdf.set_crs("EPSG:27700") # the .prj was missing — declare it explicitly
gdf = gdf.to_crs("EPSG:4326")
Convert on ingestion and never write one. Anything you would use a shapefile to store is better stored as GeoParquet, and the conversion is one line.
Jump to heading FlatGeobuf — one file, spatially indexed, no server
FlatGeobuf is a binary, self-describing format with a packed Hilbert R-tree at the front of the file. That index is the interesting part: a reader that can issue HTTP range requests can fetch the index, work out which byte ranges hold the features in a bounding box, and fetch only those — so a bounding-box read of a two-gigabyte file on object storage transfers a few hundred kilobytes and needs no server at all.
# Reading only what a viewport needs, straight from object storage.
url = "https://storage.example.com/layers/parcels.fgb"
viewport = gpd.read_file(url, bbox=(-0.51, 51.28, 0.33, 51.69))
That makes it an excellent fit for large static reference layers served from a bucket: no PostGIS, no tile server, no application code between the browser’s request and the bytes. Its limitation is that it is row-oriented, so reading two columns still reads every column of the matching features.
Jump to heading GeoParquet — the default for anything you cache
GeoParquet is Parquet with a metadata convention for geometry, which means it inherits everything Parquet already does well: columnar storage, per-column compression, row-group statistics that let a reader skip whole blocks, and a schema that carries dtypes and the CRS. For a cached dashboard layer it is the strongest of the five, and the reason is column pruning rather than raw size — a frame with thirty attribute columns of which the map draws three reads a fraction of the file.
# Read three columns of one region, skipping the rest of the file entirely.
gdf = gpd.read_parquet(
"s3://layers/parcels.parquet",
columns=["parcel_id", "category", "geometry"],
filters=[("region", "==", "camden")], # row-group pruning
)
Partition large layers by the column you filter on most, usually region or date. A partitioned dataset lets the reader skip entire files rather than entire row groups, and the difference on a national dataset is between reading four hundred megabytes and reading eleven.
Jump to heading Vector tiles — a rendering format that is not a dataset
Vector tiles invert the problem. Rather than sending features and letting the client work out what to draw, the data is pre-clipped into a pyramid of tiles, one per zoom level and grid square, with geometry simplified appropriately for each zoom. The browser fetches the handful of tiles covering the viewport and draws them; it never sees the underlying dataset and never parses it.
tippecanoe -o parcels.mbtiles \
--maximum-zoom=14 --minimum-zoom=6 \
--drop-densest-as-needed \
--extend-zooms-if-still-dropping \
parcels.geojson
The trade is that a tile is not a dataset. You cannot query it, join to it, or recover the original geometry from it, and rebaking after a data change takes minutes. Tiles are the right answer above roughly fifty megabytes of already-simplified geometry, where no amount of payload trimming will make a whole-layer transfer acceptable — and the wrong answer for anything that changes per request, because the pyramid is a build artefact.
Jump to heading Cloud-Optimized GeoTIFF — the raster equivalent of a range request
A COG is an ordinary GeoTIFF whose internal layout — tiled rather than striped, with overviews — is arranged so a reader can fetch one window over HTTP range requests without downloading the file. For a dashboard showing imagery this is the difference between a viewport read costing a few hundred kilobytes and costing the whole scene.
import rasterio
from rasterio.windows import from_bounds
with rasterio.open("https://storage.example.com/imagery/2026-05.tif") as src:
window = from_bounds(*bbox_3857, transform=src.transform)
tile = src.read(1, window=window, out_shape=(512, 512)) # overview level chosen for you
Passing out_shape matters: it lets rasterio serve the request from an overview rather than from full resolution, which is what makes a zoomed-out view cheap. A COG read without it fetches full-resolution pixels and downsamples them locally, which is correct and slow.
Jump to heading Converting between them safely
Conversion is where metadata is lost, and the losses are silent. Four rules cover almost every case.
Set the CRS before writing, never after. A frame written without one produces a file that every reader has to guess about, and the guess is usually EPSG:4326 whether or not that is true.
Check validity before writing to a binary format. GeoJSON will happily carry a self-intersecting polygon; some binary writers will refuse it, and others will write it and produce a file that fails to read. Repair first, as converting Shapely geometries describes.
Preserve dtypes deliberately. A round trip through GeoJSON turns every number into a float and every category into a string, because JSON has no other option. Round-tripping through Parquet preserves them exactly, which is one more reason to keep GeoJSON at the boundary.
Record what the conversion changed. A pipeline that simplifies, reprojects and prunes columns on the way into a cache is producing a derived dataset, and six months later somebody will ask why the totals differ from the source. Write the tolerance, the EPSG code and the column list into the file’s metadata, where the answer travels with the data.
Jump to heading Troubleshooting
gdf.crs is None after reading a shapefile. The .prj is missing from the bundle. Declare the CRS explicitly from the data’s documentation — do not guess EPSG:4326 because the numbers look like degrees, since British National Grid eastings also look plausible if you are not paying attention.
A GeoParquet file written by one library will not open in another. The geometry metadata convention has versions. Write with a recent GeoPandas and read with a recent GeoPandas, and if a third-party tool must consume it, check which version of the specification it supports before assuming the file is at fault.
A FlatGeobuf bounding-box read downloads the whole file. The server does not support HTTP range requests, or the URL is being served through something that strips the Range header. Range support is the entire point of the format; without it, it is merely a compact file.
Vector tiles show gaps between features at low zoom. Tippecanoe dropped features to fit the tile size limit. --drop-densest-as-needed is doing its job; if the gaps matter, raise the maximum tile size or aggregate the layer server-side before baking rather than expecting the tiler to solve a data-density problem.
A COG read is slow and transfers everything. The file is a GeoTIFF but not cloud-optimized — no internal tiling, no overviews. rio cogeo validate will say so, and rio cogeo create will fix it in one pass.
Jump to heading Performance considerations
The decision that matters most is not which format but whether the read is partial. A dashboard that reads one viewport at a time from a partitioned GeoParquet dataset or an indexed FlatGeobuf on object storage will outperform one that loads a whole layer from any format, and the gap widens with the size of the dataset rather than staying constant.
The second decision is where the simplification happens. A layer simplified once at write time into per-zoom tiers costs nothing at read time; a layer simplified per request pays for it on every interaction, and the result is identical. This is the same reasoning that produces the tiered cache in caching strategies and it applies just as directly to storage.
Finally, measure the format change rather than assuming it. Converting a shapefile to GeoParquet reliably improves read time; converting a small GeoJSON to FlatGeobuf often does not, because at a few thousand features the index costs more to read than the scan it replaces. The formats on this page are tools for specific problems, and a layer that does not have the problem does not need the tool.
Back to Spatial Data Reference.