Amsterdam's buildings from GeoParquet to one PMTiles file

Paper-craft illustration of a beige box with a blue and orange strap holding stacked map sheets, loose map tiles on the floor, and an orange arrow pointing to a browser window showing a city map with a river, roads and parks.
Cover illustration; it does not show data from this article.

Can one static file replace a tile server for a city's building footprints, and what do you lose compared with a live spatial database? For 110,365 Overture buildings in central Amsterdam, a 7.2 MB PMTiles archive built in under 4 seconds covers zooms 12 to 15. The cost is that every question you did not bake into the tiles at build time is gone, and the build quietly makes some of those choices for you.

Setup

  • Data: Overture Maps release 2026-10-21.0, which the Overture STAC catalog marks as latest. Theme buildings, type building, licence ODbL. The files are GeoParquet 1.1 with a bbox covering column.
  • Area: lon 4.83 to 4.96, lat 52.335 to 52.40, roughly inside Amsterdam's A10 ring.
  • Tools: GDAL 3.12.4, tippecanoe 2.79.0 (the changelog is at 2.82.0), go-pmtiles CLI 1.31.2, pmtiles JS 4.4.0, MapLibre GL JS 5.17.0.
  • Hardware: Apple M1 Pro (10 cores), 32 GB RAM, macOS. On the home connection, S3 throughput during the run varied between 10 and 150 KB/s.

Pulling one city out of Overture's GeoParquet

The release has 512 building files. Each STAC item carries a bbox, and only part-00232 covers this area. That file is 608 MB, holds 4.97 million rows and has 256 row groups. GDAL's Parquet driver reads the GeoParquet geo metadata, finds the covering column and skips row groups whose bbox statistics miss the filter:

ogr2ogr -f Parquet amsterdam_buildings.parquet \
  /vsicurl/https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-10-21.0/theme=buildings/type=building/part-00232-12e83493-af01-5cc3-be61-baaaaa2fb6e7-c000.zstd.parquet \
  -spat 4.83 52.335 4.96 52.40 \
  -select id,subtype,class,height,num_floors \
  -lco COMPRESSION=ZSTD -lco WRITE_COVERING_BBOX=YES -lco SORT_BY_BBOX=YES

The debug log showed Bounding box column 'bbox' detected and 14/256 row groups selected. Against the S3 URL, though, GDAL fetched one column chunk at a time at about 10 KB/s, and four parallel curl streams together managed about 50 KB/s. So we read the footer with a short pyarrow script, applied the same bbox test to the row-group statistics (it also chose 14 row groups), and fetched those column chunks with 12 parallel range requests into a sparse local copy. Then we ran the same ogr2ogr command on that copy. It took 1.6 s.

Measure Value
Bytes read from the 608 MB file 1,543,282 footer + 32,867,771 column chunks (5.7%)
Rows in the 14 row groups 266,754
Buildings intersecting the box 110,365
Output GeoParquet (zstd) 12,295,013 bytes, 2 row groups
Non-null height / class / subtype / num_floors 69.3% / 67.2% / 67.3% / 12.2%
First source dataset OpenStreetMap 109,671, Microsoft ML Buildings 698

All rows carry ODbL. Following Overture's attribution page, the archive credits OpenStreetMap contributors, Microsoft's Global ML Building Footprints and the Overture Maps Foundation.

Cutting tiles, and the flags that matter

tippecanoe has written .pmtiles straight from -o since 2.17.0, but it does not read Parquet. Its README lists GeoJSON, CSV and FlatGeobuf as inputs. The first conversion to FlatGeobuf failed with Mismatched geometry type. Feature geometry type is Polygon, expected layer geometry type is Multi Polygon, because Overture mixes the two types. Adding -nlt PROMOTE_TO_MULTI fixed it. The FlatGeobuf file is 40.1 MB, against 12.3 MB of GeoParquet, and the conversion took 0.5 s.

ogr2ogr -f FlatGeobuf amsterdam_buildings.fgb amsterdam_buildings.parquet \
  -nln buildings -nlt PROMOTE_TO_MULTI

tippecanoe -o amsterdam_buildings.pmtiles -l buildings \
  -Z12 -z15 --drop-smallest-as-needed \
  -y height -y num_floors -y class -y subtype \
  --attribution="© OpenStreetMap contributors (ODbL), Microsoft Global ML Building Footprints (ODbL), Overture Maps Foundation" \
  amsterdam_buildings.fgb

Why each flag:

  • -Z12: below zoom 12, one city's buildings render as texture. Leave those zooms to a basemap.
  • -z15: at 52.4° N a z15 tile is about 750 m wide, so the default 4096-unit extent gives about 18 cm per unit. MapLibre overzooms z15 tiles for closer views.
  • --drop-smallest-as-needed: when a tile goes over the 500 KB default, tippecanoe drops the physically smallest polygons first. Sheds and garages go before city blocks.
  • -y: an allow-list of attributes. Everything else, including Overture's id, stays out.

We built six variants from the same input. Times are wall-clock. Where several runs were made, the range is shown.

Build Difference Zooms Size Time Feature pieces in z12 tiles
A README building recipe: -zg --drop-densest-as-needed --extend-zooms-if-still-dropping, all attributes 2–14 10.29 MB 12.6–17.2 s 21,603
B the command above 12–15 7.17 MB 3.5–3.9 s 47,584
C B plus -y id 12–15 13.03 MB 4.1 s 19,857
D B with -z14 12–14 4.71 MB 3.3 s not counted
E B with --coalesce-densest-as-needed 12–15 7.47 MB 5.4 s 28,644
F GDAL ogr2ogr -f PMTiles from the GeoParquet, -dsco MINZOOM=12 -dsco MAXZOOM=15 12–15 7.33 MB 15.7–20.6 s 104,667

Feature counts come from ogrinfo -oo ZOOM_LEVEL=z. They count pieces, so a building clipped by a tile edge is counted once per tile. Three results surprised us.

First, the id costs 82%. Overture ids are 36-character UUIDs. Every value is unique, so MVT's per-tile value table cannot share any of them. Leaving the id out is the main reason B is 7.2 MB instead of 13 MB. Leaving it out also means you cannot join a clicked building back to the source without a spatial lookup.

Second, adding that attribute changed which buildings appear at low zoom. tippecanoe records its decisions in the archive's strategies metadata. For B, z12 shows dropped_as_needed: 70547 with tile_size_desired: 762147: the densest tile wanted 762 KB, and z13 needed no dropping. For C, z12 dropped 98,301 features and z13 dropped 86,805 (tile_size_desired: 1002721). Counting distinct ids in C gives 18,326 buildings at z12, 29,066 at z13, 110,266 at z14 and 110,345 at z15. Adding one attribute removed about three quarters of the buildings at z13. The trigger is the densest tile over the old centre. The drop count, 86,805 of roughly 118,000 pieces, suggests the size threshold is applied across the whole zoom level. The README describes that behaviour for the related densest-first option.

Third, the README recipe guessed maxzoom 14 and generated every zoom from 2 up. Zooms 2 to 11 hold 2.3 MB of tiles nobody needs for a city layer, and the largest tile at each of z9, z10 and z11 is over 400 KB. pmtiles verify rejected the archive: header MinZoom=0 does not match min tile z 2. MapLibre may never trip over that, but other tools that trust the header will.

GDAL's writer needs no intermediate file. It kept far more of z12 (104,667 pieces in 1.14 MB). Its driver docs say tiles over MAX_SIZE lose precision or features, and here most features survived. It was four to five times slower than tippecanoe and does not record what it changed.

What is inside the archive

pmtiles show reports spec version 3, clustered, gzip for both directories and tiles, and 203 tiles. The root directory is 637 bytes, there are no leaf directories, and metadata is 1,966 bytes. Per zoom:

Zoom Tiles Total Median tile p95 tile Largest tile
12 6 759 KB 32.5 KB 450.3 KB 450.3 KB
13 12 1,761 KB 126.7 KB 323.6 KB 412.1 KB
14 42 2,071 KB 49.2 KB 130.0 KB 157.9 KB
15 143 2,407 KB 12.0 KB 42.5 KB 56.3 KB

The largest tile, 12/2103/1346, is 461,138 bytes and sits over the old centre. Stopping at z14 (build D) saves a third of the file. z15 adds detail only at very close zoom.

The v3 spec fixes the layout. A 127-byte header and the compressed root directory must fit in the first 16,384 bytes. Tile IDs run along a Hilbert curve, and a clustered archive stores tile data in that order. For a file this size, one 16 KiB read gives the client the whole index. Overture's own planet archive shows the other end of the scale. We read its 127-byte header: buildings.pmtiles for this release is 180,679,746,134 bytes, covers z0 to z14 with 16,068,537 tiles, has a 14,124-byte root directory and keeps 34 MB of leaf directories after the tile data. There, the first tile in a new area costs an extra leaf-directory request.

The browser side

The client is a MapLibre page with the pmtiles protocol registered. This is the essential part:

const protocol = new pmtiles.Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile);

const map = new maplibregl.Map({
  container: "map",
  center: [4.8897, 52.37], zoom: 14,
  style: { version: 8,
    sources: { overture: { type: "vector",
      url: "pmtiles://" + new URL("amsterdam_buildings.pmtiles", location.href).href,
      attribution: "© OpenStreetMap contributors (ODbL), Microsoft Global ML Building Footprints (ODbL), Overture Maps Foundation" } },
    layers: [{ id: "buildings", type: "fill", source: "overture", "source-layer": "buildings",
      paint: { "fill-color": ["case", ["has", "height"],
        ["interpolate", ["linear"], ["get", "height"], 0, "#cde2fb", 25, "#2a78d6", 60, "#0d366b"], "#d6d3cb"] } }] }
});

A headless Chromium smoke test of this page, run against a placeholder file, showed the first request: Range: bytes=0-16383, answered 206 with 16,384 bytes. In the pmtiles 4.4.0 source, every tile then gets its own fetch with its own Range header. The library does not merge neighbouring tiles into one request, so the number of requests per view equals the number of tiles in view, plus leaf directories on large archives.

We did not run the measured browser pass against the final archive, so we have no observed request counts, transferred bytes or screenshot for first load, zoom and pan. The tile table gives the bounds. The whole z14 level is 42 tiles and 2.07 MB, and the entire archive is 7.17 MB, which is the most any viewer can download.

Hosting gotchas, checked with curl

We put the archive behind four local nginx configurations and Python's built-in server, then sent the browser's first request (Range: bytes=0-16383, Accept-Encoding: gzip, deflate, br, zstd):

Server Response Effect on pmtiles JS 4.4.0
nginx, plain static 206, Content-Length: 16384, strong ETag works
Python 3.12.8 http.server 200, Content-Length: 1000000 (whole file) the library rejects a 200 larger than the request: "Check that your storage backend supports HTTP Byte Serving."
nginx, gzip on; gzip_types application/octet-stream 200, chunked, Content-Encoding: gzip, weak W/ ETag Range ignored, same rejection
nginx on a second origin, no CORS headers 206 but no Access-Control-Allow-Origin browser blocks the cross-origin read
nginx on a second origin, Access-Control-Allow-Origin: *, Access-Control-Expose-Headers: ETag 206 works, and the ETag is readable

The gzip row is the one to watch on real hosts. nginx serves the unknown .pmtiles extension as application/octet-stream, so any "compress everything" rule picks it up and Range stops working. Tiles inside the archive are already gzipped, so you gain nothing from compressing them again.

ETag exposure fails silently. pmtiles JS reads the ETag to detect that the file was replaced mid-session and ignores weak ETags. Without Access-Control-Expose-Headers: ETag on a cross-origin host, that check never runs. A single bytes=a-b Range header is CORS-safelisted, so these plain requests need no preflight. The Protomaps storage guide still recommends allowing range and if-match, exposing etag, and answering OPTIONS on nginx.

Overture's CloudFront endpoint shows a correct setup. It answers over HTTP/2 with 206, access-control-allow-origin: *, exposed ETag, Content-Length, Content-Range, Accept-Ranges and access-control-max-age: 3000. It also sends x-amz-expiration with a "release data 60 day retention" rule, so a page that hotlinks a release-specific archive stops working once that release is removed.

What you give up compared with a live database

PMTiles is a container of pre-cut vector tiles read with Range requests. It is not a query engine. The Cloud-Optimized Geospatial Formats Guide puts it plainly: the format is for visualization, not analysis.

  • Filters only work on what is in the tiles. A MapLibre filter on height > 20 works because height was allow-listed. Filtering on source dataset, update time or anything else means a rebuild.
  • Low zooms are incomplete by design. At z12 the chosen archive holds 47,584 feature pieces for 110,365 buildings. A count, a legend or a click at that zoom describes the survivors, and the survivors change with attribute choices, as build C showed.
  • Geometry is clipped, quantized and simplified per tile. Areas and lengths measured from tiles are approximate, and buildings that cross tile edges come in pieces.
  • Every data change is a rebuild and re-upload. The build takes 4 s here. The real cost is the upload, CDN invalidation, and clients holding a stale header until the ETag changes.
  • Access control stops at the URL. Anyone who can read one tile can read every byte of the file with Range requests. A database can enforce row-level security per user. A static file cannot.
  • Each tile is one HTTP GET, which object stores bill per request. The Protomaps guide lists S3, Azure and several others as HTTP/1.1 only, where many small requests cost more round trips.

Which one to use

Situation Better fit
Data refreshes daily or monthly, read-only display, many viewers, cheap hosting Precomputed PMTiles archive
Edits land during the day, or users need parameters in the request (date range, attribute, owner) Dynamic tiles from PostGIS: Martin function sources take URL query parameters as JSON, pg_tileserv accepts a CQL filter, or your own endpoint around ST_AsMVT
Per-user visibility rules Dynamic tiles behind auth, never a public archive
Counts, joins, nearest-neighbour, exports Query the database or the GeoParquet itself, not tiles
Static display plus live attributes on click Archive for geometry with a compact key, then a lookup API. Budget for the key: the UUID cost 82% here

The GeoParquet guide frames the source side the same way. GeoParquet suits analytical reads and is immutable once written, so a pipeline that rebuilds tiles from a new release snapshot fits both formats.

What we would test next: the request count and transfer for first load, zoom and pan against this 7.2 MB archive, behind an HTTP/1.1 bucket and an HTTP/2 CDN. With a 12 KB median tile at z15, we expect request count, not bytes, to drive cost and latency, but we have not measured it. The other open question is how far a shorter integer key, instead of the 36-character id, closes the 82% gap while keeping the click-to-database join.

Sources