Serving Spatial Data Explained: Files, Features and Tiles
On this page
Problem statement
Somebody needs the data. The reflex is to send a file, and it works exactly once β until the data changes, until a second person asks, or until a browser has to display it.
At that point there are three shapes a spatial service can take, and choosing the wrong one is expensive:
- A file β a GeoPackage, a GeoParquet, a zipped shapefile. Simple, cacheable, and stale the moment it is copied.
- A feature API β query by bounding box and attribute, get GeoJSON back. Flexible, and its cost is proportional to what it returns.
- Tiles β pre-cut pyramids of vector or raster data. Fast, cacheable, and only useful for drawing.
The numbers decide more of this than the architecture does. Measured on Natural Earth's 4,596 provinces (Examples 2 and 4):
whole dataset as GeoJSON 68.35 MB (20.80 MB gzipped)
same data at 1e-6Β° (about 11 cm) 46.44 MB (12.39 MB gzipped)
simplified to 0.01Β° 30.68 MB ( 7.50 MB gzipped)
as GeoParquet 21.21 MB
one page of 50 features 1.01 MB ( 0.34 MB gzipped)
a city-sized bounding box, 1 feature 20,884 B ( 0.01 MB gzipped)
The bounding-box response is more than three thousand times smaller than the whole layer. That ratio is the whole argument for an API.
Quick answer
Match the shape to what the consumer does with the data:
def service_shape(*, consumer, data_changes, dataset_mb, needs_attributes):
if consumer == "analyst" and not data_changes:
return "a file β GeoParquet or GeoPackage, on a URL"
if consumer == "map" and not needs_attributes:
return "tiles β vector tiles or PMTiles"
if consumer == "map" and dataset_mb > 20:
return "tiles, plus a feature API for the click-through"
if consumer == "application":
return "a feature API with bbox and attribute filters"
return "a file, until somebody proves otherwise"
The last line is not a joke. A signed URL to a Parquet file on object storage is the cheapest thing on this list, and it is the right answer more often than the alternatives suggest.
Consumer, response size, allowed queries, separate drawing path
1. Ask what the consumer does with the response
Three consumers, three needs:
- An analyst wants the whole thing, once, in a format their tools open. A file is correct.
- A map wants the part on screen at the current zoom, quickly, and does not need attributes until something is clicked. Tiles are correct.
- An application wants specific features by attribute or location, and their properties. A feature API is correct.
Most systems have all three consumers and try to serve them with one endpoint, which is how a feature API ends up returning 68 MB of GeoJSON to a browser.
2. Size the response before designing the endpoint
Two numbers settle most design arguments: how big the full dataset is as GeoJSON, and how big a typical response is.
Measured, a bounding-box query around Madrid returning 1 of 4,596 features was 20,884 bytes (Example 1). The whole layer as GeoJSON is 68.35 MB. An API without a filter is a file download with extra steps.
3. Decide what the client is allowed to ask for
Three limits worth enforcing from the first version:
- A maximum page size β and a maximum that is genuinely small. A page of 1,000 features is 15.59 MB and one server worker managed 2.6 requests per second of it; a page of 50 is 1.01 MB and managed 23.1 (measured under load).
- A required bounding box for anything unbounded, or a default that is not "the world".
- A coordinate precision limit β six decimal places is about 11 cm, and cutting to it took a third off the payload in measurement.
4. Separate the drawing path from the querying path
A map drawing a layer needs geometry at the right generalisation, and nothing else. A popup needs one feature's attributes.
Serving both from one GeoJSON endpoint forces the drawing path to carry attributes it will not use and the query path to carry geometry it does not need. Tiles for drawing plus a feature endpoint for the details is more moving parts and much less traffic.
5. Make the second request free
Caching is the cheapest performance work available on an HTTP service. Measured under load on one worker: serialising a 50-feature page on every request served 23.1 requests per second; a stored body served 586.4 at 16.3 ms; and the 304 path β the same endpoint answering a conditional request β served 1,318.3 at 5.3 ms.
Fifty-seven times the throughput of serialising on every request, and 2.2 times that of a stored body, for an ETag header and a comparison.
6. Choose the response format deliberately
GeoJSON is the default because everything reads it, and it is verbose: the same 4,596 features were 68.35 MB as GeoJSON and 21.21 MB as GeoParquet.
Vector tiles are smaller still and only carry what a renderer needs. Parquet is excellent for an analyst and unreadable to a browser. The format is a property of the consumer, not of the data.
Three response shapes for the same provinces
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. The setup also writes it as GeoParquet for Example 1's file endpoint.
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")
layer = gpd.read_file("provinces.gpkg")
layer.to_parquet("provinces.parquet")
print(len(layer), "polygons;", f"provinces.parquet {os.path.getsize('provinces.parquet') / 1e6:.2f} MB")
4596 polygons; provinces.parquet 21.21 MB
Example 1 β the three shapes, side by side
from fastapi import FastAPI, Query, Response
import geopandas as gpd
import mapbox_vector_tile
import morecantile
import shapely
app = FastAPI()
LAYER = gpd.read_file("provinces.gpkg")
MERCATOR = LAYER.to_crs(3857)
TMS = morecantile.tms.get("WebMercatorQuad")
def build_mvt(layer, z, x, y, extent=4096):
"""Clip to the tile, rescale to tile coordinates, encode. Geometry and a name only."""
b = TMS.xy_bounds(morecantile.Tile(x, y, z))
clipped = MERCATOR.clip((b.left, b.bottom, b.right, b.top))
features = [{"geometry": shapely.affinity.affine_transform(g, [
extent / (b.right - b.left), 0, 0, extent / (b.top - b.bottom),
-b.left * extent / (b.right - b.left), -b.bottom * extent / (b.top - b.bottom)]).wkt,
"properties": {"name": n}} for g, n in zip(clipped.geometry, clipped["name"])]
return mapbox_vector_tile.encode([{"name": "provinces", "features": features}], default_options={"extents": extent})
@app.get("/download/provinces.parquet")
def whole_file():
"""Shape 1: the file. One request, cacheable forever, no server work."""
return Response(open("provinces.parquet", "rb").read(),
media_type="application/vnd.apache.parquet",
headers={"Cache-Control": "public, max-age=86400"})
@app.get("/features")
def features(bbox: str | None = None, limit: int = Query(50, le=1000), offset: int = 0):
"""Shape 2: the feature API. Cost is proportional to what it returns."""
subset = LAYER
if bbox:
x0, y0, x1, y1 = (float(v) for v in bbox.split(","))
subset = subset.cx[x0:x1, y0:y1]
page = subset.iloc[offset:offset + limit]
return Response(page.to_json(), media_type="application/geo+json")
@app.get("/tiles/{z}/{x}/{y}.mvt")
def tile(z: int, x: int, y: int):
"""Shape 3: tiles. Fixed cost per tile, cacheable, geometry only."""
return Response(build_mvt(LAYER, z, x, y), media_type="application/vnd.mapbox-vector-tile",
headers={"Cache-Control": "public, max-age=3600"})
from fastapi.testclient import TestClient
client = TestClient(app)
for url in ("/download/provinces.parquet", "/features?limit=50", "/features?bbox=-3.89,40.31,-3.52,40.56",
"/tiles/4/7/6.mvt"):
r = client.get(url, headers={"Accept-Encoding": "identity"})
print(f"{url:42} {r.status_code} {len(r.content):>11,} bytes {r.headers['content-type']}")
/download/provinces.parquet 200 21,206,905 bytes application/vnd.apache.parquet
/features?limit=50 200 1,010,943 bytes application/geo+json
/features?bbox=-3.89,40.31,-3.52,40.56 200 20,884 bytes application/geo+json
/tiles/4/7/6.mvt 200 30,863 bytes application/vnd.mapbox-vector-tile
Example 2 β measuring what your endpoint will actually send
import gzip
import json
def payload_report(gdf, page_sizes=(10, 50, 200, 1000), bbox=None):
"""The numbers that decide the API design, on your own data."""
whole = gdf.to_json().encode()
print(f"{'response':28} {'raw':>10} {'gzipped':>10}")
print(f"{'whole dataset':28} {len(whole) / 1e6:9.2f}M "
f"{len(gzip.compress(whole, 6)) / 1e6:9.2f}M")
for n in page_sizes:
page = gdf.iloc[:n].to_json().encode()
print(f"{'page of ' + str(n):28} {len(page) / 1e6:9.2f}M "
f"{len(gzip.compress(page, 6)) / 1e6:9.2f}M")
if bbox:
subset = gdf.cx[bbox[0]:bbox[2], bbox[1]:bbox[3]]
body = subset.to_json().encode()
print(f"{'bbox (' + str(len(subset)) + ' features)':28} "
f"{len(body) / 1e6:9.2f}M {len(gzip.compress(body, 6)) / 1e6:9.2f}M")
import shapely
reduced = gdf.copy()
reduced["geometry"] = shapely.set_precision(reduced.geometry.values, 1e-5)
body = reduced.to_json().encode()
print(f"{'whole, 1 m precision':28} {len(body) / 1e6:9.2f}M "
f"{len(gzip.compress(body, 6)) / 1e6:9.2f}M")
payload_report(layer, bbox=(-3.89, 40.31, -3.52, 40.56)) # a city-sized box around Madrid
response raw gzipped
whole dataset 68.35M 20.80M
page of 10 0.25M 0.08M
page of 50 1.01M 0.34M
page of 200 4.00M 1.32M
page of 1000 15.59M 5.00M
bbox (1 features) 0.02M 0.01M
whole, 1 m precision 43.85M 11.05M
Example 3 β a limits policy expressed as code
from dataclasses import dataclass
@dataclass
class ServiceLimits:
max_page: int = 200
default_page: int = 50
require_bbox_above: int = 500 # features
max_bbox_area_deg2: float = 25.0
coordinate_precision: int = 6 # ~11 cm
max_response_mb: float = 5.0
def check(self, request_limit, bbox, estimated_features):
problems = []
if request_limit > self.max_page:
problems.append(f"limit {request_limit} exceeds max {self.max_page}")
if estimated_features > self.require_bbox_above and not bbox:
problems.append(
f"{estimated_features:,} features match; a bbox is required above "
f"{self.require_bbox_above:,}")
if bbox:
area = abs((bbox[2] - bbox[0]) * (bbox[3] - bbox[1]))
if area > self.max_bbox_area_deg2:
problems.append(f"bbox covers {area:.1f} degΒ², max is "
f"{self.max_bbox_area_deg2}")
return problems
limits = ServiceLimits()
print(limits.check(50, (-3.89, 40.31, -3.52, 40.56), 1))
print(limits.check(1000, None, len(layer)))
print(limits.check(50, (-10, 35, 30, 60), 1613))
[]
['limit 1000 exceeds max 200', '4,596 features match; a bbox is required above 500']
['bbox covers 1000.0 degΒ², max is 25.0']
Writing the limits down as an object makes them reviewable, testable and documentable β and it stops the default page size drifting upwards one pull request at a time.
Example 4 β the other sizes in the table
full = layer.to_json().encode()
for label, gdf in (("coordinates at 1e-6 degrees (~11 cm)",
layer.assign(geometry=shapely.set_precision(layer.geometry.values, 1e-6))),
("simplified to 0.01 degrees", layer.assign(geometry=layer.geometry.simplify(0.01)))):
body = gdf.to_json().encode()
print(f"{label:38} {len(body) / 1e6:6.2f} MB gzipped {len(gzip.compress(body, 6)) / 1e6:5.2f} MB "
f"({1 - len(body) / len(full):.0%} smaller)")
print(f"{'GeoParquet':38} {os.path.getsize('provinces.parquet') / 1e6:6.2f} MB")
coordinates at 1e-6 degrees (~11 cm) 46.44 MB gzipped 12.39 MB (32% smaller)
simplified to 0.01 degrees 30.68 MB gzipped 7.50 MB (55% smaller)
GeoParquet 21.21 MB
Bounding boxes, precision and page size
Why the bounding box is the whole point
A feature API without a spatial filter is a download endpoint that costs more to run. With one, the response is proportional to the area asked for β and the measured ratio between a full dataset and a city-sized bounding box was 68.35 MB against 20,884 bytes, more than three thousand to one.
That is why the bounding box parameter is not a feature to add later. It is the reason the service exists.
Why coordinate precision is free money
GeoJSON writes coordinates as decimal text, and the default is whatever precision the source had β frequently fifteen significant figures, describing a position to a fraction of a nanometre.
Rounding to six decimal places is about 11 cm at the equator, which is finer than the accuracy of almost any dataset. Measured, it took the payload from 68.35 MB to 46.44 MB β a 32% reduction with no visible change to any consumer. Rounding to five places, about 1 m, gave 43.85 MB: most of the saving is in dropping the meaningless digits, not in how many real ones are kept.
Why the page size matters more than the page count
Pagination is usually introduced to bound the response, and it only works if the page is genuinely small. Measured: a page of 1,000 features is 15.59 MB and one worker sustained 2.6 requests per second; a page of 50 is 1.01 MB at 23.1 requests per second.
The server was not slow. It was serialising and transmitting fifteen times as much data per request, which is what the numbers say.
Why tiles and features are different services
A tile is a fixed-cost, cacheable, geometry-only response designed for a renderer. A feature is a variable-cost, query-driven, attribute-carrying response designed for a program.
Trying to serve a map from a feature API means sending attributes the renderer discards and geometry at full precision the screen cannot show. Trying to serve an application from tiles means reconstructing attributes that were never included. Two consumers, two services β and both can sit on the same data.
Object storage, CRS84, gzip and default limits
- A file on object storage is a spatial service and often the right one. Do not skip it.
- GeoJSON must be EPSG:4326 by specification; other CRSs are legal in practice and confusing.
Cache-Controlon a file endpoint is free throughput.- Gzip is not optional β it took a 50-feature page from 1.01 MB to 0.34 MB (Example 2).
- A default
limitwith no maximum is a maximum of infinity. - Bounding boxes crossing the antimeridian need explicit handling, or they select the whole world.
- Attribute filters need indexes once the dataset outgrows memory.
- Measure the payload before designing the endpoint. It settles most arguments.
Internal links
- OGC API Features explained β the standard for the feature shape
- GeoJSON, vector tiles or Parquet: choosing an API response format β the format decision in depth
- Dynamic tile server or static tiles: what to serve β the tile shape
- How to add bounding box and attribute filters to a spatial API β implementing the filter
- How to paginate a large feature API in Python β bounding the response
- HTTP caching for spatial data: ETags, max-age and invalidation β making the second request free
- How to build a GeoJSON API with FastAPI β the working implementation
- Fixing a GeoJSON that is too big for the browser β the failure this design avoids
FAQ
Should I serve a file or build an API?
A file, unless the data changes, the audience is more than one person, or a browser needs part of it. A signed URL to a GeoParquet file is the cheapest service on the list.
How much smaller is a bounding-box response?
Measured on a 4,596-polygon dataset: the whole thing was 68.35 MB and a city-sized bounding box returning 1 feature was 20,884 bytes β a ratio of more than three thousand to one.
What page size should a feature API use?
Small. A page of 50 features is 1.01 MB and one worker served 23.1 a second; a page of 1,000 is 15.59 MB and it served 2.6.
Should I serve tiles or features?
Both, for different consumers. Tiles are fixed-cost, cacheable and geometry-only for drawing; features are query-driven and carry attributes for applications.
Does coordinate precision matter?
Substantially. Rounding to six decimal places β about 11 cm β took the payload from 68.35 MB to 46.44 MB with no visible change.
What is the single cheapest improvement?
Caching. A conditional request answered with 304 served 1,318 requests per second, against 586 for a stored body returned in full and 23 for a page serialised on every request.