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.

The three shapes a spatial service can take and what each consumer needs. consumer decides the shapeanalyst β†’ a fileβœ“wants the whole thing, onceβœ“GeoParquet or GeoPackage on a URLβœ“cacheable forever, no server workβœ“the cheapest service that existsmap β†’ tiles Β· app β†’ featuresβœ“a map wants geometry at this zoomβœ“an app wants specific rows and attributesβœ“tiles are fixed-cost and cacheableβœ“features are query-drivenMost systems have all three consumers and try to serve them from one endpoint spatialworkflow.io
That is how a feature API ends up returning 68 MB of GeoJSON to a browser.

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.

Response size for the same 4,596-polygon dataset, from whole file to one bounding box. the same data, six responseswhole dataset68.35 MBat 1e-6 degrees46.44 MBsimplified 0.01Β°30.68 MBas GeoParquet21.21 MBpage of 501.01 MBone bounding box20,884 bytes035 MB70 MBThe bounding box is over three thousand times smaller than the layer β€” that is why an API exists spatialworkflow.io
An API without a spatial filter is a download endpoint that costs more to run.

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.

The limits a spatial API should enforce from its first version. limits, from version onea maximum page size50, not 10,000a bbox required above a match countor a default that is not the worldcoordinate precision capped6 dp β‰ˆ 11 cma maximum bbox areathe unfiltered request in disguiseno limit at allthe worst case is the datasetA request that cannot be enormous cannot be abused, whoever sends it spatialworkflow.io
Write them down as an object β€” defaults drift upward one pull request at a time.

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-Control on 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 limit with 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.

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.