Serving Spatial Data Explained: Files, Features and Tiles
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 a 4,596-polygon dataset:
whole dataset as GeoJSON 54.06 MB (18.05 MB gzipped)
same data at 1 m coordinate precision 29.56 MB ( 8.85 MB gzipped)
simplified to 0.01ยฐ 16.42 MB ( 5.31 MB gzipped)
as GeoParquet 18.82 MB
one page of 50 features 0.86 MB ( 0.30 MB gzipped)
one bounding box, 2 features 0.06 MB ( 0.02 MB gzipped)
The bounding box is nine hundred times smaller than the file. 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.
Step-by-step solution
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 54 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 returning 2 of 4,596 features was 58.8 kB uncompressed and 20.8 kB on the wire. The same endpoint asked for everything returned 54 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. Measured, a page of 1,000 features was 12.4 MB and the server managed 2.3 requests per second; a page of 50 was 857 kB at 32.5 requests per second.
- 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 halved 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 on a precomputed response: the 200 path served 100 requests per second at 94.6 ms, and the 304 path โ the same endpoint answering a conditional request โ served 1,191 requests per second at 6.2 ms.
Twelve times the throughput, 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 54 MB as GeoJSON and 18.8 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.
Code examples
Example 1 โ the three shapes, side by side
from fastapi import FastAPI, Query, Response
import geopandas as gpd
app = FastAPI()
LAYER = gpd.read_file("provinces.gpkg")
@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"})
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")
response raw gzipped
whole dataset 54.06M 18.05M
page of 10 0.22M 0.07M
page of 50 0.86M 0.30M
page of 200 3.37M 1.17M
page of 1000 12.43M 4.31M
bbox (2 features) 0.06M 0.02M
whole, 1 m precision 29.56M 8.85M
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
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.
Explanation
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 54.06 MB against 0.06 MB, roughly nine hundred 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 54.06 MB to 29.56 MB โ a 45% reduction with no visible change to any consumer.
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 was 12.4 MB and the server sustained 2.3 requests per second; a page of 50 was 857 kB at 32.5 requests per second.
The server was not slow. It was serialising and transmitting fourteen times as much data per request, which is exactly 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.
Edge cases or notes
- 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 measured payload from 857 kB to 299 kB on the wire.
- 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 54.06 MB and a city-sized bounding box returning 2 features was 58.8 kB โ a ratio of about nine hundred to one.
What page size should a feature API use?
Small. A page of 50 features was 857 kB at 32.5 requests per second; a page of 1,000 was 12.4 MB at 2.3 requests per second on the same server.
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 a measured payload from 54.06 MB to 29.56 MB with no visible change.
What is the single cheapest improvement?
Caching. A conditional request answered with 304 served 1,191 requests per second against 100 for the same body returned in full.