GeoJSON, Vector Tiles or Parquet: Choosing an API Response Format

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                         54.06 MB   18.05 MB
GeoJSON, 1 m coordinate precision      29.56 MB    8.85 MB
GeoJSON, simplified to 0.01ยฐ           16.42 MB    5.31 MB
GeoParquet                             18.82 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)
Bar chart of response size in four formats plus two GeoJSON reductions.
After compression GeoJSON and GeoParquet are nearly equal in bytes โ€” and not in capability.

Step-by-step solution

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, property names repeat on every feature, and there is no binary encoding. Measured, the same features that fit in 18.8 MB of GeoParquet took 54.06 MB as GeoJSON.

Two reductions apply before considering another format:

  • Round the coordinates. Six decimal places is about 11 cm; the measured payload fell 45%, from 54.06 MB to 29.56 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, 18.82 MB against GeoJSON's 54.06 MB for the same features โ€” and its real advantage is that a client can read part of it: measured elsewhere, 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 a real response: 857,061 bytes became 299,123 on the wire โ€” a factor of 2.87.

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.

Grid of four consumer types against the response format each needs.
A client measuring areas from vector tiles is measuring the tile grid.

Code examples

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

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
format                                bytes      gzipped
GeoJSON as-is                         54.06M       18.05M
GeoJSON, 6 dp                         29.56M        8.85M
GeoJSON, simplified 0.01ยฐ             16.42M        5.31M
GeoParquet                            18.82M       18.82M

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"),
}

Writing the policy down โ€” including the reason โ€” is what stops a "small change" adding a 54 MB code path to an endpoint designed for tiles.

Explanation

Why GeoJSON is so much larger

Three structural costs, none of which have a fix inside the format:

  • Coordinates are decimal text. -57.836116004496425 is nineteen bytes for a value a 4-byte float would carry adequately.
  • Property names repeat. Every feature carries the full key name for every property.
  • There is no shared dictionary. A column with 253 distinct values repeats them a million times.

A columnar binary format fixes all three at once, which is why GeoParquet came in at 35% of GeoJSON's size before compression.

Why rounding is the best-value change

Coordinate precision beyond the accuracy of the data is pure payload. Six decimal places is 11 cm at the equator; most source data is nowhere near that accurate, and no consumer detects the difference.

Measured, it removed 45% โ€” from 54.06 MB to 29.56 MB โ€” with no change to the geometry any consumer can perceive. Nothing else on this page is that cheap.

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

Gzip is very effective on repetitive text: the measured GeoJSON response fell from 857,061 to 299,123 bytes, a factor of 2.87. Parquet is already compressed internally, so gzip adds almost nothing.

After compression the measured comparison is 18.05 MB of gzipped GeoJSON against 18.82 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. On the same measurements elsewhere, a selective query against a remote Parquet file transferred 0.06% of the file.

Four layers explaining why GeoJSON is verbose and why it is still useful.
The last row is why GeoJSON remains the right default for small responses.

Edge cases or notes

  • GeoJSON is specified as CRS84 โ€” longitude first, WGS 84. Other CRSs are common and confusing.
  • Vary: Accept is required when negotiating, or caches will serve the wrong format.
  • A query parameter (?f=json) is friendlier for browser testing than an Accept header.
  • Vector tiles need a tiling scheme, not just a format choice.
  • GeoParquet in a browser needs a WASM reader; do not make it the default for a web client.
  • FlatGeobuf's spatial index lets a client range-request a bounding box out of a plain file.
  • Round coordinates on output, never in storage.
  • Measure on your own data. These ratios depend heavily on geometry complexity.

FAQ

Is GeoJSON too big to serve?

It is the largest reasonable option. Measured, 4,596 polygons were 54.06 MB as GeoJSON and 18.82 MB as GeoParquet โ€” and rounding the coordinates to six decimal places alone removed 45%.

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 18.05 MB gzipped against 18.82 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 54.06 MB to 29.56 MB with no perceptible change.