GeoJSON, Vector Tiles or Parquet: Choosing an API Response Format
On this page
Problem statement
GeoJSON is the default because every tool reads it. It is also the largest of the reasonable options, by a factor that is easy to underestimate. Measured on the same 4,596 polygons:
format size gzipped
GeoJSON, as-is 68.35 MB 20.80 MB
GeoJSON, 6 decimal places 46.44 MB 12.39 MB
GeoJSON, simplified to 0.01Β° 30.68 MB 7.50 MB
GeoParquet 21.21 MB β
And for a client that only draws the data, a vector tile carries less again, because it contains no attributes it will not use and no precision the screen cannot show.
The format is not a matter of preference. It is a property of what the consumer does with the response, and getting it wrong costs a factor of three before any other optimisation is attempted.
Quick answer
def format_for(consumer):
return {
"browser map, drawing": "vector tiles (MVT) or PMTiles",
"browser app, needs attributes": "GeoJSON, filtered and rounded",
"python or R analyst": "GeoParquet",
"another service": "GeoParquet, or GeoJSON if it must be text",
"human inspecting by hand": "GeoJSON β readable matters more than size",
"bulk transfer": "GeoParquet or FlatGeobuf",
}[consumer]
Where the client can accept several, negotiate:
@app.get("/features")
def features(request: Request):
accept = request.headers.get("accept", "")
if "application/vnd.apache.parquet" in accept:
return parquet_response(subset)
if "application/vnd.mapbox-vector-tile" in accept:
return mvt_response(subset)
return geojson_response(subset)
GeoJSON, vector tiles, GeoParquet and FlatGeobuf
1. GeoJSON: the default, and what it costs
Every language reads it, every tool accepts it, and it is human-readable β which is why it is right for small responses, debugging and anything a person will look at.
Its costs are structural: coordinates are decimal text (-58.200111852217844 is nineteen bytes for a value a 4-byte float carries adequately), every feature repeats every property name β 121 of them per province here β and there is no shared dictionary for repeated values. Measured, the same features that fit in 21.2 MB of GeoParquet took 68.35 MB as GeoJSON.
Two reductions apply before considering another format:
- Round the coordinates. Six decimal places is about 11 cm; the measured payload fell 32%, from 68.35 MB to 46.44 MB.
- Send only the properties the client uses.
2. Vector tiles: for drawing, and only for drawing
A Mapbox Vector Tile contains geometry quantised to a tile-local integer grid, clipped to the tile, with only the attributes the style needs. It is designed to be rendered, not analysed.
That makes it dramatically smaller than the equivalent GeoJSON, and unsuitable for anything that needs exact coordinates: the geometry has been snapped to the tile's grid and clipped at its edges.
Serve tiles for the map and a feature endpoint for the click-through. The two carry different things and neither substitutes for the other.
3. GeoParquet: for anything analytical
Columnar, binary, compressed, with the CRS in the file metadata. Measured, 21.21 MB against GeoJSON's 68.35 MB for the same features β and its real advantage is that a client can read part of it: in Columnar vs row storage, a filtered query against a remote 446 MB Parquet file transferred 0.26 MB.
For an analyst on the other end of the connection, GeoParquet is the correct answer and it is not close. For a browser, it is unreadable without a WASM library.
4. FlatGeobuf: streamable and spatially indexed
A binary format with a packed Hilbert R-tree, so a client can fetch the features in a bounding box using HTTP range requests, without a server-side query.
It sits between the others: smaller than GeoJSON, readable by GDAL and several JavaScript clients, and streamable feature by feature. It is the right answer when you want a file that behaves like an API.
5. Negotiate, but keep one default that always works
Content negotiation is standard HTTP and worth implementing, with two caveats:
- The default must be the safe one. A client sending
Accept: */*should get GeoJSON. - A query parameter is friendlier than a header for people testing in a browser:
?f=json,?f=parquet. OGC API Features specifies exactly this.
6. Compress everything text
Gzip is close to free and it is the largest single reduction available for GeoJSON. Measured on this page's own endpoint, a request for Europe (?bbox=-10,35,30,60, 1,613 provinces): 11,196,624 bytes became 2,576,298 on the wire β a factor of 4.35.
Binary formats compress much less, because they are already compressed. That narrows the gap between GeoJSON and GeoParquet on the wire, and does not close it.
One endpoint, several formats, measured
The data used on this page
provinces.gpkg is Natural Earth's 1:10m admin-1 states and provinces layer: 4,596 polygons with 121 attribute columns, a realistic size for a feature API.
import os
import geopandas as gpd
if not os.path.exists("provinces.gpkg"):
gpd.read_file("https://naciscdn.org/naturalearth/10m/cultural/ne_10m_admin_1_states_provinces.zip") \
.to_file("provinces.gpkg")
print(len(gpd.read_file("provinces.gpkg")))
4596
Example 1 β one endpoint, three formats
import io
import json
from fastapi import FastAPI, Query, Request, Response
import geopandas as gpd
import shapely
app = FastAPI()
LAYER = gpd.read_file("provinces.gpkg")
MEDIA = {
"json": "application/geo+json",
"parquet": "application/vnd.apache.parquet",
"mvt": "application/vnd.mapbox-vector-tile",
}
def geojson_response(gdf, precision=6):
reduced = gdf.copy()
reduced["geometry"] = shapely.set_precision(reduced.geometry.values,
10 ** -precision)
return Response(reduced.to_json(), media_type=MEDIA["json"],
headers={"Vary": "Accept"})
def parquet_response(gdf):
buffer = io.BytesIO()
gdf.to_parquet(buffer)
return Response(buffer.getvalue(), media_type=MEDIA["parquet"],
headers={"Vary": "Accept",
"Content-Disposition": 'attachment; filename="features.parquet"'})
@app.get("/features")
def features(request: Request, f: str | None = Query(None, pattern="^(json|parquet)$"),
bbox: str | None = None):
subset = LAYER
if bbox:
x0, y0, x1, y1 = (float(v) for v in bbox.split(","))
subset = subset.cx[x0:x1, y0:y1]
wanted = f or negotiate(request.headers.get("accept", ""))
if wanted == "parquet":
return parquet_response(subset)
return geojson_response(subset)
def negotiate(accept_header: str) -> str:
for token, name in (("vnd.apache.parquet", "parquet"),
("vnd.mapbox-vector-tile", "mvt")):
if token in accept_header:
return name
return "json" # the safe default
Calling it, as a browser and as an analyst would:
import gzip
from fastapi.testclient import TestClient
client = TestClient(app)
europe = client.get("/features", params={"bbox": "-10,35,30,60"})
body = europe.content
packed = gzip.compress(body, 6)
print(f"/features?bbox=-10,35,30,60 -> {len(europe.json()['features'])} features, "
f"{len(body):,} bytes, {len(packed):,} gzipped ({len(body) / len(packed):.2f}x)")
print(europe.headers["content-type"], "|",
client.get("/features", params={"bbox": "-10,35,30,60"},
headers={"accept": "application/vnd.apache.parquet"}).headers["content-type"])
/features?bbox=-10,35,30,60 -> 1613 features, 11,196,624 bytes, 2,576,298 gzipped (4.35x)
application/geo+json | application/vnd.apache.parquet
Example 2 β measuring the formats on your own data
import gzip
import io
def format_report(gdf, precision=6, simplify_deg=0.01):
import shapely
rows = []
body = gdf.to_json().encode()
rows.append(("GeoJSON as-is", len(body), len(gzip.compress(body, 6))))
rounded = gdf.copy()
rounded["geometry"] = shapely.set_precision(rounded.geometry.values,
10 ** -precision)
body = rounded.to_json().encode()
rows.append((f"GeoJSON, {precision} dp", len(body), len(gzip.compress(body, 6))))
simplified = gdf.copy()
simplified["geometry"] = simplified.geometry.simplify(simplify_deg,
preserve_topology=True)
body = simplified.to_json().encode()
rows.append((f"GeoJSON, simplified {simplify_deg}Β°", len(body),
len(gzip.compress(body, 6))))
buffer = io.BytesIO()
gdf.to_parquet(buffer)
rows.append(("GeoParquet", buffer.tell(), buffer.tell()))
print(f"{'format':30} {'bytes':>12} {'gzipped':>12}")
for name, raw, gz in rows:
print(f"{name:30} {raw / 1e6:11.2f}M {gz / 1e6:11.2f}M")
return rows
rows = format_report(LAYER)
format bytes gzipped
GeoJSON as-is 68.35M 20.80M
GeoJSON, 6 dp 46.44M 12.39M
GeoJSON, simplified 0.01Β° 30.68M 7.50M
GeoParquet 21.21M 21.21M
Example 3 β a format policy per endpoint
from dataclasses import dataclass
@dataclass
class EndpointFormat:
default: str
allowed: tuple
precision: int | None
simplify_deg: float | None
reason: str
POLICY = {
"/tiles/{z}/{x}/{y}.mvt": EndpointFormat(
"mvt", ("mvt",), None, None,
"drawing only; geometry is quantised to the tile grid"),
"/features": EndpointFormat(
"json", ("json", "parquet"), 6, None,
"applications need attributes and exact-enough coordinates"),
"/download": EndpointFormat(
"parquet", ("parquet", "json"), None, None,
"analysts want the whole thing in a format their tools open"),
}
Example 4 β the other numbers on this page
import json
first = json.loads(LAYER.iloc[:1].to_json())["features"][0]["geometry"]["coordinates"]
while isinstance(first[0], list):
first = first[0]
print(f"{len(LAYER):,} features, {LAYER.shape[1] - 1} properties each; first coordinate {first[0]!r} "
f"= {len(repr(first[0]))} bytes of text")
print(f"'admin' column: {LAYER['admin'].nunique()} distinct values over {len(LAYER):,} rows")
4,596 features, 121 properties each; first coordinate -58.200111852217844 = 19 bytes of text
'admin' column: 253 distinct values over 4,596 rows
Writing the policy down β including the reason β is what stops a "small change" adding a 68 MB code path to an endpoint designed for tiles.
What a tile and compression do and do not change
Why a vector tile is not a small GeoJSON
The geometry in an MVT is quantised to a tile-local grid, usually 4,096 units across the tile, and clipped at the tile boundary. Reconstructing world coordinates from it gives you the tile's resolution, not the source's.
That is a feature for drawing β it is exactly the precision the screen can show β and a defect for anything else. A client that measures areas from vector tiles is measuring the tile grid.
Why compression narrows the gap without closing it
After compression the measured comparison is 20.80 MB of gzipped GeoJSON against 21.21 MB of GeoParquet β nearly equal in bytes. Parquet still wins for an analyst, because of what a client can do with it: read one column, filter row groups, and skip the rest. In Columnar vs row storage, a selective query against a remote Parquet file transferred 0.06% of it.
Caches, tiling schemes and where to round
Vary: Acceptis required when negotiating, or caches will serve the wrong format.- Vector tiles need a tiling scheme, not just a format choice.
- Round coordinates on output, never in storage.
- Measure on your own data. These ratios depend heavily on geometry complexity.
Internal links
- Serving spatial data explained: files, features and tiles β matching format to consumer
- OGC API Features explained β the
f=parameter convention - Vector tiles explained β what a tile actually contains
- GeoParquet and columnar storage explained β the analytical format
- GIS file formats compared β the wider landscape
- How to prepare a GeoDataFrame for the web β reducing before serving
- Fixing a GeoJSON that is too big for the browser β the failure this prevents
- How to set cache headers on a spatial API and tile service β
Varyand caching
FAQ
Is GeoJSON too big to serve?
It is the largest reasonable option. Measured, 4,596 polygons were 68.35 MB as GeoJSON and 21.21 MB as GeoParquet β and rounding the coordinates to six decimal places alone removed 32%.
When should I serve vector tiles?
When the client is drawing. A tile's geometry is quantised to the tile grid and clipped at its edges, which is right for rendering and wrong for measurement.
Should my API return GeoParquet?
For analytical consumers, yes β it is smaller, typed, carries the CRS, and lets a client read only the columns and row groups it needs. Not as a browser default.
Does gzip make GeoJSON competitive?
Nearly, on bytes: a measured 20.80 MB gzipped against 21.21 MB of GeoParquet. It does not give the client column pruning or partial reads.
How do I let clients choose a format?
Content negotiation on Accept, plus a query parameter such as ?f=parquet for browser testing. Send Vary: Accept so caches do not serve the wrong one.
What is the cheapest size reduction?
Rounding coordinates. Six decimal places is about 11 cm and took a measured payload from 68.35 MB to 46.44 MB with no perceptible change.