PMTiles and Cloud-Native Tile Archives
Every team that publishes a map eventually runs a tile server, and every team that runs a tile server eventually resents it. The process itself does almost nothing interesting: it accepts a z/x/y triple, looks up a blob, and returns it with a content type. Yet it has to be deployed, monitored, autoscaled for traffic spikes it cannot predict, and kept alive during the 95% of the week when nobody is looking at the map. The bytes it serves are immutable. The lookup is a hash. There is no reason for a computer to be awake for it.
A cloud-native tile archive removes the process. It packs an entire tileset — every tile at every zoom, plus an index that maps a tile coordinate to a byte range — into one object in cloud storage, structured so a browser can fetch any single tile with an HTTP range request. PMTiles is the format that made this practical: a header, a tree of directories, and a contiguous run of compressed tile payloads, all readable with the same Range: header that a video player uses to seek. The archive is a file, the file is the service, and the CDN in front of the bucket is the cache tier.
This page belongs to the Geospatial Storage Fundamentals & Format Comparison section, and it sits at the delivery end of the pipeline the rest of that section describes: GeoParquet is where analytical geometry lives, and a tile archive is what you generate from it when the consumer is a map rather than a query engine. The two are complementary, not competing — analytical storage answers which features match, tile storage answers what should this screen show.
Prerequisites
- Python 3.10+, with
geopandas>=1.0andpyarrow>=16to read the source GeoParquet - Tippecanoe 2.x (or
pmtilesCLI 1.x) to generate and pack tiles; both are Go/C++ binaries invoked as subprocesses - Object storage that honours
Range:requests and lets you setCache-Controland CORS headers — S3, R2, GCS, and Azure Blob all qualify - A source dataset with a stable CRS, normalized to EPSG:4326 before tiling; a tiler will happily produce a tileset from mixed-CRS input and the result will be silently misplaced
- A CDN in front of the bucket for anything with more than a handful of readers
Rough sizing before you start: a national-scale parcel layer of ~12 million polygons produces a 3–8 GB archive over zooms 4–14 depending on how aggressively geometry is simplified per zoom. A country-scale points-of-interest layer of 2 million points fits comfortably under 400 MB. If your estimate lands above 20 GB, plan for a split archive from the beginning rather than discovering the need after the first build.
How a Tile Archive Answers a Request
The whole design rests on one observation: a tileset is an immutable key–value store whose keys are known in advance. If the keys are ordered on a space-filling curve and the index is stored alongside the values, a reader that knows the ordering can compute where to look with arithmetic instead of a directory listing.
PMTiles orders tiles by Hilbert index within each zoom level, the same locality principle covered under space-filling curves for spatial partitioning. Tiles that are adjacent on screen end up adjacent in the file, so the leaf directory that describes one tile almost always describes its neighbours too, and a CDN caching a 64 KB range serves several tiles from one fetch. The index itself is a shallow tree: a root directory small enough to arrive with the header, and leaf directories fetched on demand.
Three properties follow from this layout, and they are the reason the format is worth adopting rather than merely interesting.
Immutability is an asset, not a limitation. Because the archive never changes, every byte in it is infinitely cacheable. You can set Cache-Control: public, max-age=31536000, immutable on the object and never think about invalidation, as long as new builds land under a new key. That is the same discipline as content-hashed static assets, applied to a multi-gigabyte map.
Request cost replaces compute cost. A tile server bills you for instance-hours whether or not anyone is looking. An archive bills you for GET requests and egress, which are proportional to actual readers. The crossover point is real and worth computing for your traffic shape — the object storage egress cost model applies directly, with the caveat that tiles make request charges matter far more than they do for analytical reads.
The client does the indexing. A PMTiles reader in the browser computes the Hilbert index of the tile it wants, walks the cached root directory, and issues the range request itself. There is no round trip to ask where is this tile — the arithmetic is local. This is why a cold map paints in roughly the same time as one backed by a server, despite having no server.
Building and Publishing an Archive
The workflow below is the one to automate. Each step exists because skipping it produces a specific, recognisable failure in production.
1. Profile the source before tiling
Tile generation is the expensive step, and its cost is driven by feature count and vertex density, not by file size. Read the source GeoParquet and measure both before you commit a machine to the job. A layer with 40 million vertices concentrated in 200,000 features behaves very differently from one with the same vertex count spread over 8 million features: the first needs aggressive per-zoom simplification, the second needs feature dropping at low zooms.
2. Choose a zoom range from the data, not from habit
The default instinct is zooms 0–14 for everything, which wastes build time at both ends. Low zooms of a city-scale dataset render an empty world; high zooms of a coarse boundary layer render nothing the previous zoom did not already show. Pick the minimum zoom at which the layer is meaningful and the maximum zoom at which additional detail exists in the source geometry, and let overzoom handle everything past it.
3. Generate tiles and pack them in one pass
Modern tilers write PMTiles directly, which avoids materialising a directory of millions of small files — a step that is slow on every filesystem and catastrophic on network storage. Compress tile payloads with gzip inside the archive; the client decompresses per tile and the CDN passes the bytes through untouched.
4. Set the object headers that make range reads work
Three headers decide whether the archive is usable from a browser: Accept-Ranges: bytes (usually automatic), a permissive Access-Control-Allow-Origin plus Access-Control-Expose-Headers: Content-Length, Content-Range for cross-origin reads, and a long Cache-Control. Missing CORS headers produce the single most common failure — a map that works in curl and shows nothing in the browser.
5. Measure requests per viewport, not just latency
The number that predicts your bill is requests per rendered viewport. Instrument it in a real client at several zooms. A healthy archive with a warm directory cache costs the visible tile count plus zero or one directory reads; if you see two or three directory reads per pan, the directory tree is deeper than it needs to be for your access pattern.
Production Implementation
The function below takes a GeoParquet source, profiles it, generates a PMTiles archive with a zoom range derived from the data, and uploads it under a content-addressed key with the headers a browser needs. It is the shape of the step you would drop into the batch conversion pipeline that already produces your GeoParquet.
# Requires: geopandas>=1.0, pyarrow>=16, boto3>=1.34, tippecanoe>=2.60 on PATH
# Python 3.10+
from __future__ import annotations
import hashlib
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path
import boto3
import geopandas as gpd
from botocore.exceptions import BotoCoreError, ClientError
@dataclass(frozen=True)
class TilesetProfile:
features: int
vertices: int
min_zoom: int
max_zoom: int
def profile_tileset(source: Path, *, detail_zoom_hint: int = 14) -> TilesetProfile:
"""Derive a zoom range from the geometry itself rather than from defaults.
Dense, small features justify deep zooms; sparse coarse polygons do not.
"""
frame = gpd.read_parquet(source)
if frame.empty:
raise ValueError(f"{source} contains no features to tile")
if frame.crs is None or frame.crs.to_epsg() != 4326:
raise ValueError("source must be normalized to EPSG:4326 before tiling")
vertices = int(frame.geometry.count_coordinates().sum())
features = int(len(frame))
density = vertices / max(features, 1)
# A layer averaging fewer than 8 vertices per feature carries no detail
# worth a deep zoom; one above 200 needs the extra levels to stay legible.
max_zoom = detail_zoom_hint - 2 if density < 8 else detail_zoom_hint
if density > 200:
max_zoom = detail_zoom_hint + 1
bounds = frame.total_bounds # (minx, miny, maxx, maxy)
span = max(bounds[2] - bounds[0], bounds[3] - bounds[1])
min_zoom = 0 if span > 90 else (4 if span > 5 else 8)
return TilesetProfile(features, vertices, min_zoom, max_zoom)
def build_archive(source: Path, out: Path, profile: TilesetProfile, layer: str) -> Path:
"""Generate tiles and pack them straight into a PMTiles archive."""
cmd = [
"tippecanoe",
"-o", str(out),
"--force",
"--layer", layer,
"--minimum-zoom", str(profile.min_zoom),
"--maximum-zoom", str(profile.max_zoom),
# Drop features rather than truncate geometry when a tile overflows,
# so shapes stay closed at every zoom.
"--drop-densest-as-needed",
"--simplification", "4",
str(source),
]
try:
subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=7200)
except subprocess.CalledProcessError as exc:
raise RuntimeError(f"tile generation failed: {exc.stderr.strip()[:400]}") from exc
except subprocess.TimeoutExpired as exc:
raise RuntimeError("tile generation exceeded its two-hour budget") from exc
return out
def publish_archive(archive: Path, bucket: str, prefix: str, *, origin: str) -> str:
"""Upload under a content-addressed key with range-read friendly headers."""
digest = hashlib.sha256(archive.read_bytes()).hexdigest()[:16]
key = f"{prefix.rstrip('/')}/{archive.stem}.{digest}.pmtiles"
client = boto3.client("s3")
try:
client.upload_file(
str(archive), bucket, key,
ExtraArgs={
"ContentType": "application/vnd.pmtiles",
# The key changes whenever the bytes change, so this object
# can be cached forever without an invalidation step.
"CacheControl": "public, max-age=31536000, immutable",
"Metadata": {"allowed-origin": origin},
},
)
except (BotoCoreError, ClientError) as exc:
raise RuntimeError(f"upload of {key} failed: {exc}") from exc
return key
def publish_geoparquet_as_tiles(
source: Path, bucket: str, prefix: str, layer: str, *, origin: str
) -> tuple[str, TilesetProfile]:
"""Full path: profile, tile, upload. Returns the object key and the profile."""
profile = profile_tileset(source)
with tempfile.TemporaryDirectory() as tmp:
archive = build_archive(source, Path(tmp) / f"{layer}.pmtiles", profile, layer)
key = publish_archive(archive, bucket, prefix, origin=origin)
return key, profile
The client side is deliberately small. A protocol handler registers the pmtiles:// scheme with the map library, and every tile request the renderer makes becomes a range read against the object:
// Requires: pmtiles@3, maplibre-gl@4
import * as pmtiles from "pmtiles";
import maplibregl from "maplibre-gl";
const protocol = new pmtiles.Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile);
new maplibregl.Map({
container: "map",
style: {
version: 8,
sources: {
parcels: {
type: "vector",
// One object. No tile server, no /{z}/{x}/{y} route.
url: "pmtiles://https://cdn.example.com/tiles/parcels.9f3c1a20b4e7d885.pmtiles",
},
},
layers: [
{ id: "parcel-fill", type: "fill", source: "parcels", "source-layer": "parcels" },
],
},
});
Reference Matrix
The numbers below come from a 12-million-polygon national parcel layer tiled over zooms 4–14, served from a bucket behind a CDN, measured at a 1440×900 viewport. They are indicative rather than universal — the ratios matter more than the absolutes.
| Delivery model | Archive / storage size | Cold viewport requests | p95 tile latency | Ops burden | Primary Use Case |
|---|---|---|---|---|---|
| PMTiles archive + CDN | 4.1 GB, one object | 12–14 (tiles) + 1 (directory) | 40 ms edge / 190 ms origin | None — a static object | Immutable published maps with unpredictable traffic |
| PMTiles archive, no CDN | 4.1 GB, one object | 12–14 + 1 | 210 ms | None, but per-request billing bites | Internal tools with a handful of readers |
Directory of z/x/y files |
4.4 GB, 2.1 M objects | 12–14 | 45 ms edge | Sync and listing cost on every rebuild | Legacy pipelines already emitting files |
| MBTiles behind a tile server | 3.9 GB SQLite + instance | 12–14 | 55 ms warm | Deploy, patch, autoscale, monitor | Tilesets that mutate through the day |
| FlatGeobuf served directly | 6.8 GB | 1–3 range reads | 300 ms first paint | None | Client-side filtering on attributes |
Two rows deserve a second look. The directory of files row costs slightly more storage than the archive because two million objects carry two million sets of metadata, and its real cost is invisible here: rebuilding it means diffing and syncing millions of keys, which takes longer than generating the tiles did. The FlatGeobuf row makes far fewer requests but paints much later, because the client fetches geometry and does the rendering work a tiler would otherwise have done at build time — the right trade only when readers need to filter on attributes the cartography did not anticipate. That trade-off is the subject of comparing GeoParquet vs FlatGeobuf performance.
Failure Modes and Gotchas
Missing CORS headers on the object. The archive downloads fine with curl, the map shows a blank canvas, and the console reports a failed range request. Browsers need Access-Control-Allow-Origin and Access-Control-Expose-Headers including Content-Range, because the reader inspects the response range to confirm the server honoured the request. Buckets default to neither. Test from a real origin, not from a local file.
Overwriting an archive in place. If you PUT a new build under the same key while readers hold a cached root directory, their byte offsets now point into different tiles, and the map renders a coherent-looking mosaic of the wrong places — a failure that looks like a data bug rather than a caching bug. Always publish under a new, content-addressed key and swap the reference, exactly as the implementation above does.
Tiling from mixed or unset CRS. A tiler assumes its input is in EPSG:4326 and will not tell you otherwise. Feed it a projected layer and it produces a valid archive containing correctly structured, geographically meaningless tiles. Normalize upstream and assert the CRS before the subprocess runs, which is the same discipline described in preserving CRS metadata in GeoParquet.
Directory depth tuned for the wrong access pattern. A deep directory tree keeps the initial fetch tiny, which flatters a synthetic first-paint benchmark, but costs an extra round trip on nearly every pan. A shallow tree does the opposite. The right depth follows from how readers move: dashboards that open at one fixed extent want shallow trees, exploratory maps want deep ones. Measure requests per viewport at the zooms your readers actually use.
Assuming an archive replaces analytical storage. Tiles are a rendering artefact — geometry is simplified per zoom, attributes are pruned, and features are dropped where density demands it. Never query a tile archive to answer a question about the data. Keep the GeoParquet, generate tiles from it, and treat the archive as a derived, disposable output of the pipeline.
Frequently Asked Questions
What is a cloud-native tile archive and how is it different from a tile server?
A cloud-native tile archive is a single file that holds every tile of a tileset plus an internal index, laid out so a client can fetch any one tile with an HTTP range request. A tile server is a running process that receives a z/x/y request and returns one tile, either from a database or from a directory of files. The archive replaces the process with a static object: there is nothing to deploy, patch, or autoscale, and the CDN in front of the bucket does the work a tile server’s cache used to do. The trade-off is that updates rewrite or re-upload an object rather than mutating rows in a live database.
How many HTTP requests does a PMTiles map actually make?
One range request per tile plus a small number of directory reads. The header and root directory arrive in a single initial fetch of roughly 16 KB, which typically covers the low zooms outright. Deeper zooms may cost one extra fetch of a leaf directory, and that leaf then serves hundreds of neighbouring tiles from cache. In practice a fresh viewport at zoom 14 costs the tiles themselves plus zero to one directory reads, and panning within the same leaf costs only tiles.
Does PMTiles work without a CDN in front of the bucket?
It works, but you pay for it. Every tile becomes a GET against the bucket, so you are billed per request and every reader pays full origin latency. A CDN collapses repeated tile reads to edge hits, which is what makes the per-request cost of an archive competitive with a running tile server. If you cannot put a CDN in front, budget for request charges explicitly rather than only for storage and egress.
Can I update one region of a tile archive without rebuilding the whole thing?
Not in place. The archive’s directory records byte offsets, so inserting a larger tile shifts everything after it. The practical pattern is to split the world into several archives along a stable boundary — by country, by basin, by administrative region — and rebuild only the archive whose source data changed. Clients then read from two or three archives instead of one, which costs a few extra directory fetches and buys independent update cadence.
Related
- PMTiles vs MBTiles for Cloud Tile Serving — the format-level comparison, including the SQLite dependency
- Serving Vector Tiles from Object Storage with Range Requests — headers, CORS, and CDN configuration in detail
- Converting GeoParquet to PMTiles with Tippecanoe — the build step end to end, with simplification settings
- Comparing GeoParquet vs FlatGeobuf Performance — the alternative when readers need attribute filtering
- Object Storage Egress Cost Modelling for Geospatial — the cost model behind the crossover chart above
← Back to Geospatial Storage Fundamentals & Format Comparison