Vector Tile Pipelines with PMTiles
Once a dataset is too large to ship as GeoJSON, the client should download only the features in view at the current zoom — that is what vector tiles deliver. This guide covers building tiles in Python-orchestrated pipelines and packaging them as PMTiles, a single-file, server-less tile archive, with MBTiles as the SQLite-based alternative when you need a running endpoint. It is the scaling layer beneath Interactive Maps with Folium and the source-of-truth behind MapLibre GL Vector Web Maps in Web Mapping & Interactive Visualization. Two focused walkthroughs sit beneath it — Generating PMTiles from GeoParquet and Serving MBTiles with Python — and both assume the tiling model described here.
Architecture & Data Structures
A vector tile is a compact, pre-clipped slice of geometry for one z/x/y cell, encoded in the Mapbox Vector Tile (MVT) protobuf format. Coordinates inside a tile are not lon/lat — they are integers on a local grid (the tile extent, conventionally 4096 units square), so a tile carries no CRS of its own and stays tiny. A tileset is the full pyramid across zoom levels: zoom 0 is one tile for the whole world, and each zoom z quarters its parent into 4^z tiles. That pyramid is the data structure every downstream concern — file size, simplification, attribute budgets — ultimately trades against.
Two containers hold the pyramid, and the choice between them decides your whole serving story:
- MBTiles — a SQLite database with one
tilesrow perz/x/ycell (plus ametadatatable for bounds, zoom range, and layer JSON). Reading a tile is an indexed SQLite query, so you need a live process in the request path. This is the container to pick when tiles must be filtered by auth, generated on demand, or served by an existing tile server. - PMTiles — a single flat file: a header, a clustered directory of tile offsets, and the tile bytes, all laid out so any tile can be fetched with one HTTP
Rangerequest against the byte range the directory points to. A static object store serves it directly; the client reads only the bytes for the tiles it needs. No server process, no database, trivial CDN caching.
The build tool is tippecanoe, a C++ binary that ingests GeoJSON, FlatGeobuf, or line-delimited GeoJSON and emits MBTiles with per-zoom simplification and feature dropping. Python's role is orchestration: it prepares and reprojects the source, invokes tippecanoe as a subprocess, converts MBTiles to PMTiles, and verifies the result. Nothing about the pipeline is a Python library rewrite of tippecanoe — you drive the binary and own everything on either side of it.
import subprocess
# tippecanoe is a native binary invoked from Python; check=True surfaces
# a non-zero exit as a CalledProcessError instead of silently continuing.
subprocess.run([
"tippecanoe",
"-o", "buildings.mbtiles",
"-zg", # choose max zoom automatically from feature density
"--drop-densest-as-needed", # shed features where a tile exceeds the size budget
"-l", "buildings", # layer name inside the tileset (what MapLibre references)
"--force", # overwrite an existing output file
"buildings.geojson",
], check=True)
The -l layer name is not cosmetic: it becomes the source-layer a MapLibre style must reference, and a mismatch there is the most common reason a correctly built tileset renders nothing. Read it back from the tileset metadata rather than trusting your memory of the build flags.
Two properties of the PMTiles layout explain why a single file can behave like a tile server, and both are worth understanding before you tune anything. The first is Hilbert ordering: tiles are not stored in z/x/y order but along a space-filling curve that keeps geographically adjacent tiles adjacent in the file. A client panning across a city therefore reads bytes that are close together, so a CDN edge that has already cached one region's byte range frequently satisfies the next request from the same cached block. Sorting by x then y would scatter a viewport's tiles across the whole archive and defeat that entirely. The second is tile deduplication: identical tile bodies are stored once and referenced by many directory entries. For any dataset with large empty areas — a coastal country's ocean tiles, a national grid over uninhabited terrain — this collapses a substantial fraction of the pyramid, which is why a PMTiles archive is often noticeably smaller than the MBTiles it came from despite carrying the same data.
The format is versioned, and the version distinction is not academic. Archives written before the v3 specification use a different directory encoding, and current readers — including the MapLibre protocol shim — expect v3. If a .pmtiles file inherited from an older project refuses to load with no useful error, check its version before debugging anything else; pmtiles convert will rewrite a v2 archive into v3 in one pass.
Environment Configuration & Dependency Resolution
Tippecanoe is a system binary, not a Python package, and there is no native Windows build — that split shapes the whole install. On macOS and Linux you install the binary once; the Python side stays lightweight because it only prepares inputs and packs the output.
# tippecanoe: system binary (no native Windows build — use WSL2 or a container)
# macOS: brew install tippecanoe
# Linux: git clone https://github.com/felt/tippecanoe && cd tippecanoe && make -j && sudo make install
#
# Python side — conda-forge keeps the GDAL/GEOS/PROJ binary stack coherent:
conda install -c conda-forge "pmtiles=3.2.*" "geopandas=0.14.*" "gdal=3.8.*" "pyogrio=0.7.*"
gdal supplies ogr2ogr, which converts a GeoPackage, Shapefile, or GeoParquet store into the GeoJSON or FlatGeobuf that tippecanoe ingests — FlatGeobuf is worth preferring for large inputs because it streams instead of loading the whole file into memory. The pmtiles Python package provides both the pmtiles convert CLI (MBTiles → PMTiles) and a reader you can use to assert on the header and tile directory in tests. pyogrio is the fast I/O engine GeoPandas uses under the hood; pin it so read_file/to_file throughput does not shift between environments.
Pin tippecanoe too. Its default zoom heuristics, the exact behaviour of -zg, and the availability of flags such as --coalesce-densest-as-needed have all changed across releases, and an unpinned CI image will silently produce a different tileset than your laptop. Record the version alongside your build flags:
import subprocess
version = subprocess.run(
["tippecanoe", "--version"], capture_output=True, text=True
).stderr.strip() # tippecanoe prints its version to stderr
print(version) # e.g. "tippecanoe v2.51.0"
Which tippecanoe you install also matters. The original Mapbox repository stopped receiving changes years ago; active development continues in the Felt fork, and several flags this pipeline relies on — shared-node handling during simplification, attribute accumulation when features are coalesced — exist only there. Installing it also puts three companion binaries on your path that most write-ups never mention: tippecanoe-decode turns a tile back into readable GeoJSON so you can see exactly what reached the client, tile-join merges and filters existing tilesets without re-tiling from source, and tippecanoe-json-tool reshapes line-delimited GeoJSON on the way in. Any debugging session that starts with "the map is empty but the build succeeded" should start with tippecanoe-decode.
On the packing side there are two independent pmtiles implementations and they are not interchangeable. The Python package is what you import — it provides the convert entry point plus Reader and Writer classes you can assert against in tests. The Go command-line tool of the same name adds operations the Python package does not have: show prints the header and metadata of a local or remote archive, verify checks structural integrity, serve runs a local HTTP endpoint with CORS already enabled for development, and extract slices a bounding box out of a remote archive using range requests, downloading only the tiles inside the box. That last command is genuinely useful in a pipeline — you can cut a city-sized archive out of a continental one hosted elsewhere without ever fetching the whole file.
Finally, know where this toolchain stops. Tippecanoe tiles your features; it is not an OpenStreetMap basemap builder. Producing a styled, multi-layer basemap from a planet or continent extract is a different job with a different tool — Planetiler, a JVM application that reads OSM PBF and writes MBTiles or PMTiles directly, does in about an hour what a naive tippecanoe pipeline over the same data would not finish. Reach for it when the deliverable is a basemap, and keep tippecanoe for the thematic layers you draw on top.
Vectorized Operations & Core Workflow
The end-to-end pipeline has four stages: produce source features (ideally GeoParquet from a cloud-native workflow), tile with tippecanoe, convert to PMTiles, and upload the single file. Everything before tiling is ordinary vectorized GeoPandas work; tippecanoe is the only non-Python step, and the PMTiles conversion is pure Python. The detailed, copy-paste recipe for the GeoParquet path is in Generating PMTiles from GeoParquet.
import subprocess
import geopandas as gpd
# 0. Prepare source: metric work stays upstream, reproject to 4326 as the LAST step
parcels = gpd.read_file("parcels.gpkg") # EPSG:25832 (UTM 32N)
parcels["area_ha"] = parcels.geometry.area / 1e4 # metric attribute computed first
parcels.to_crs(epsg=4326).to_file( # FlatGeobuf streams into tippecanoe
"parcels_4326.fgb", driver="FlatGeobuf"
)
# 1. Vector features → MBTiles (tippecanoe does the tiling + per-zoom simplification)
subprocess.run([
"tippecanoe", "-o", "parcels.mbtiles", "-zg",
"--coalesce-densest-as-needed", # merge, not drop, where tiles get too dense
"-l", "parcels",
"--force", "parcels_4326.fgb",
], check=True)
# 2. MBTiles → PMTiles (single-file, static-host ready)
subprocess.run(["pmtiles", "convert", "parcels.mbtiles", "parcels.pmtiles"], check=True)
Two flags carry most of the tuning weight. -zg ("guess") lets tippecanoe pick the maximum zoom from feature density instead of you hard-coding it — good for a first pass, but pin an explicit --maximum-zoom once you know the deepest zoom users actually reach, because every extra zoom level roughly quadruples the tile count. --coalesce-densest-as-needed and --drop-densest-as-needed are the two ways to stay under the per-tile size budget: coalesce merges adjacent features of the same layer (right for contiguous polygons like parcels or land use), drop removes whole features (right for point clouds where thinning is acceptable). Choosing the wrong one is why a map either loses features at low zoom or renders as an unreadable smear.
For point data specifically, add --cluster-distance or -r1 to keep low zooms legible, and prefer dropping over coalescing — merging point geometries is rarely meaningful.
The stage most pipelines skip is looking at what the build actually produced. Because MBTiles is a plain SQLite database, one query tells you where the bytes went, and that distribution is the fastest route to a smaller archive:
import sqlite3
con = sqlite3.connect("file:parcels.mbtiles?mode=ro", uri=True)
rows = con.execute("""
SELECT zoom_level,
COUNT(*) AS tiles,
SUM(LENGTH(tile_data)) AS bytes,
MAX(LENGTH(tile_data)) AS largest
FROM tiles GROUP BY zoom_level ORDER BY zoom_level
""").fetchall()
for zoom, tiles, total, largest in rows:
print(f"z{zoom:<3} {tiles:>8,} tiles {total/1e6:>8.1f} MB largest {largest/1e3:>6.1f} KB")
# z12 1,984 tiles 12.4 MB largest 164.2 KB
# z13 7,612 tiles 41.9 MB largest 238.7 KB
# z14 29,455 tiles 186.3 MB largest 491.0 KB
Two signals matter in that table. The deepest zoom almost always dominates the total, which is the concrete form of the rule that each extra level roughly quadruples the tile count — if z14 is 70% of the archive and your users never zoom past 13, one flag change halves the file. And a largest value pressed up against 500 KB means tippecanoe is hitting its per-tile budget and shedding data to stay under it; that is the tile where features or attributes are quietly going missing, and it is worth decoding to see what survived.
Geometry / Data Processing Details
Tippecanoe simplifies geometry per zoom level, so low zooms carry coarse outlines and high zooms carry full detail. You control that trade-off with --simplification (the Douglas-Peucker tolerance, higher = more aggressive), the density flags above, and --no-simplification-of-shared-nodes to stop adjacent polygons from developing gaps along their shared borders when simplified independently. The last flag matters for any polygon coverage — administrative boundaries, parcels, land use — where a visible sliver between two shapes is a defect.
The non-negotiable pre-step is topology validation. Tippecanoe will tile an invalid polygon without complaint — a self-intersecting ring or an unclosed shell tiles "successfully" and the artifact only surfaces as a rendering glitch on the map, often at one specific zoom. Validate and repair before tiling, using the full pipeline in Topology Validation & Repair:
import geopandas as gpd
from shapely.validation import make_valid
land_use = gpd.read_file("land_use.gpkg") # EPSG:25832
land_use["geometry"] = land_use.geometry.apply(make_valid)
land_use = land_use[~land_use.geometry.is_empty]
land_use = land_use[land_use.geometry.is_valid]
# Simplify in the PROJECTED CRS so the tolerance is in metres, then reproject
land_use["geometry"] = land_use.geometry.simplify(5) # 5 m — pre-thin before tiling
land_use.to_crs(epsg=4326).to_parquet("land_use_4326.parquet")
Attribute handling is a second silent trap. Tippecanoe drops attributes once a tile exceeds its size budget, and it drops them in an order you did not choose, so a field your MapLibre style depends on can vanish from dense tiles. Whitelist exactly the fields the client needs with -y, which both guarantees they survive and shrinks the tiles:
import subprocess
subprocess.run([
"tippecanoe", "-o", "land_use.mbtiles", "-zg",
"-y", "land_use_class", # keep only the fields the style + tooltip use
"-y", "area_ha",
"-l", "land_use", "--force", "land_use_4326.parquet",
], check=True)
Coalescing raises a question the flag alone does not answer: when two adjacent parcels merge into one shape at zoom 9, what happens to their attributes? By default the surviving feature keeps one set of values and the rest are discarded, which turns a population field into a meaningless sample. --accumulate-attribute (short form -E) tells tippecanoe how to combine them instead:
import subprocess
subprocess.run([
"tippecanoe", "-o", "census_blocks.mbtiles", "-zg",
"--coalesce-densest-as-needed",
"-E", "population:sum", # merged blocks carry the summed population
"-E", "median_income:mean", # …and an averaged income, not a random one
"-y", "population", "-y", "median_income",
"-l", "census_blocks", "--force", "census_blocks_4326.geojsonl",
], check=True)
sum is right for counts and areas, mean for rates and densities, max for anything you are thresholding on. Choosing nothing is the option that silently produces a low-zoom choropleth that disagrees with the high-zoom one — a defect that survives review because both maps look plausible in isolation.
The other knob worth knowing is tile detail. -d (--full-detail) sets the coordinate precision inside each tile as a power of two; the default of 12 gives the conventional 4096-unit grid. Dropping to 10 quantises geometry to a 1024-unit grid, which shrinks tiles measurably and is invisible for area fills, but visibly staircases linework at high zoom. --low-detail applies the same idea only to zoom levels below the maximum, which is usually the better trade: coarse where nobody is looking closely, full precision where they are.
A useful mental model: do all heavy geometry work — validity repair, metric simplification, attribute pruning — in Python where you can inspect and test it, and let tippecanoe handle only the pyramid it is uniquely good at. Anything you can fix upstream is cheaper to fix upstream.
CRS Alignment & Projection Pipeline
Vector tiles are addressed in the Web Mercator tile grid, but tippecanoe expects EPSG:4326 (lon/lat) input and performs the Mercator projection itself during tiling. This is the single rule that most often breaks a pipeline: hand tippecanoe anything other than 4326 and every feature lands in the wrong place, because it interprets your projected metres or foreign degrees as lon/lat. Reproject to 4326 as the final step, after all metric processing is done, using the transformer patterns in Coordinate Systems with PyProj and the workflow in Coordinate Reference System Transformations.
import geopandas as gpd
buildings = gpd.read_file("buildings.gpkg") # EPSG:25833 (UTM 33N)
assert buildings.crs is not None, "unknown CRS — set it before anything else"
buildings["footprint_m2"] = buildings.geometry.area # metric attribute FIRST, in UTM
buildings_4326 = buildings.to_crs(epsg=4326) # reproject as the LAST step
assert buildings_4326.crs.to_epsg() == 4326, "tippecanoe requires EPSG:4326"
buildings_4326.to_file("buildings_4326.fgb", driver="FlatGeobuf")
Two CRS gotchas recur here. First, a GeoDataFrame loaded with crs=None will not be reprojected by to_crs() — it raises or passes the raw numbers straight through — so always assert the CRS on load rather than assuming the file declared it. Second, do not compute areas, lengths, or buffer distances after the reprojection to 4326: degrees are not a length unit, and set_precision/simplify tolerances expressed in degrees apply inconsistently across latitudes. Keep EPSG:3857 out of the analysis entirely — it is the tile grid's internal projection, never a CRS you should measure in. Use an appropriate projected CRS (a UTM zone, a national grid) for all metric work, and treat the 4326 conversion purely as the delivery hand-off.
There is one legitimate exception to the "4326 only" rule, and knowing it stops people from working around the wrong problem. Tippecanoe accepts a -s (--projection) argument declaring the input's projection, so -s EPSG:3857 will correctly tile a source already in Web Mercator metres. It is worth using only when the data genuinely arrives that way and reprojecting it would be a wasted round trip; it is not a licence to feed the tiler a UTM zone, because 4326 and 3857 are the only two values it understands. When in doubt, reproject in Python where the transformation is explicit and testable.
A quieter consequence of the fixed tile grid is that separately built tilesets always align. Two archives produced on different days, on different machines, from different source CRSs, place z14/8623/5610 over exactly the same patch of ground, because the grid is a property of the specification rather than of the build. That is what makes layered maps and incremental rebuilds tractable: you can rebuild the parcels archive without touching the buildings archive and the two still register perfectly. It also means a misregistration on screen is never a grid problem — it is a datum problem in one of the sources, usually a national grid whose transformation to WGS 84 needed a grid shift file that PROJ could not find. The diagnosis for that lives in Fixing PyProj CRS Transformation Errors; the symptom is a consistent offset of a few metres to a few hundred metres, identical across the whole layer.
Production Export & Integration
The output format decision follows directly from the container model, and it is worth making explicitly rather than by default:
- PMTiles for static hosting. Upload one file to S3, Cloudflare R2, or any bucket that honours HTTP
Rangerequests; MapLibre reads it through thepmtiles://protocol shim registered before map construction. No server process, trivial CDN caching, and cost that is basically storage plus egress. This is the right default for public, read-only maps and the target of Generating PMTiles from GeoParquet. - MBTiles behind a server. When you need authentication, per-request filtering, or an existing tile endpoint (a Flask/FastAPI service,
tileserver-gl, ormartin), keep the SQLite container and serve tiles from it — the full pattern is in Serving MBTiles with Python. - Source from GeoParquet. Tiling integrates naturally with Cloud-Native Geospatial Formats: keep the analytical store as GeoParquet, treat tiles as a derived rendering artifact, and rebuild them from the store whenever the data changes rather than editing tiles in place.
A short pre-deploy checklist catches most production surprises: verify the tileset before you upload, confirm the layer name matches the style, and check the byte size against your CDN's expectations. The pmtiles reader makes verification a unit test rather than a manual click-through:
from pmtiles.reader import Reader, MmapSource
with open("parcels.pmtiles", "rb") as f:
reader = Reader(MmapSource(f))
header = reader.header()
meta = reader.metadata()
# Guardrails you can assert on in CI
assert header["max_zoom"] >= 14, "not deep enough for street-level detail"
assert meta["vector_layers"][0]["id"] == "parcels", "layer name must match the style source-layer"
print(f"zooms {header['min_zoom']}–{header['max_zoom']}, "
f"bounds {header['min_lon_e7']/1e7:.4f}..{header['max_lon_e7']/1e7:.4f}")
Set long, immutable cache headers on the PMTiles object (Cache-Control: public, max-age=31536000, immutable) and bust the cache by changing the filename on each rebuild — parcels.v3.pmtiles — rather than overwriting in place, so a client mid-session never mixes tiles from two builds.
The bucket itself needs exactly two capabilities, and a deploy fails if either is missing. It must answer Range requests with 206 Partial Content (every major object store does, but a reverse proxy or a naive Python file handler in front of it may not), and it must return CORS headers, because the page fetching the archive is almost never on the same origin as the bucket. Both are one-time configuration, and both produce failures that look nothing like their cause — a missing Range capability makes the client download the entire archive to read one tile, which presents as "the map is slow" rather than as an error.
# Minimal S3-compatible CORS policy for a public PMTiles archive
aws s3api put-bucket-cors --bucket tiles-example --cors-configuration '{
"CORSRules": [{
"AllowedOrigins": ["https://maps.example.com"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["range", "if-match"],
"ExposeHeaders": ["ETag", "Content-Range", "Accept-Ranges"],
"MaxAgeSeconds": 3600
}]
}'
ExposeHeaders is the line people omit. Without Content-Range and Accept-Ranges exposed to script, the browser receives the partial response and hides the headers the shim needs to interpret it, producing a map that loads a first tile and then stalls.
CDN behaviour deserves a decision rather than a default. Edges differ in how they treat partial responses: some fetch and cache the full object on the first range request and serve every later range from that copy, which is ideal for an archive of a few hundred megabytes; others cache each byte range as its own entry, which is fine too but warms up more slowly; and a few do not cache partial responses at all, which turns every tile request into an origin fetch and quietly multiplies your egress bill. Test it before launch by requesting the same tile twice and reading the cache-status header, not by reasoning about the documentation.
Two client-side behaviours round out the integration picture. Overzoom means a map can zoom past the archive's maximum: MapLibre keeps rendering the deepest available tiles, scaled up, so capping --maximum-zoom at 14 does not stop users reaching zoom 18 — it only means the geometry stops gaining detail there. For most thematic layers that is an entirely acceptable trade and the single most effective way to shrink an archive. Local development does not need a bucket at all: pmtiles serve exposes a directory of archives over HTTP with range support and permissive CORS, so the page you will ship to a CDN can be developed against exactly the same protocol path.
Where does this stop scaling? Not at the archive size — multi-gigabyte PMTiles files work fine, because the client only ever reads kilobytes of them. The limits are practical ones: build time and scratch disk grow with the deepest zoom, so a planet-scale archive is a Planetiler job rather than a tippecanoe job; a viewport at high zoom issues on the order of a dozen range requests, which HTTP/2 multiplexes happily but a proxy that serialises connections will not; and any workflow needing per-user filtering has left the static model entirely and belongs behind a process.
Windows / Platform Edge Cases & Debugging
- No native tippecanoe on Windows. Use WSL2 or a Linux container for the tiling step; the PMTiles packaging and verification in pure Python run anywhere once the MBTiles exists, so a common split is tile in WSL2, convert and test on the host.
- Tiles look empty at low zoom. Features were dropped by a density flag. Raise the detail with a lower
--drop-rate, switch--drop-densest-as-neededto--coalesce-densest-as-neededfor polygons, or set an explicit--minimum-zoom. - PMTiles won't load in MapLibre. The
pmtiles://protocol shim was not registered before the map was constructed — register the protocol first, then create the map. - Nothing renders even though the tileset is valid. The style's
source-layerdoes not match the tippecanoe-llayer name. Read the real name back from the tileset metadata and fix the style. - Huge MBTiles / PMTiles file. Over-detailed at high zoom; cap
--maximum-zoomto what users actually reach and prune attributes with-y. Doubling the max zoom can quadruple the file. - Attributes missing in dense tiles. Tippecanoe dropped them past the size budget; whitelist the needed fields with
-y field_nameso they always survive. - Everything is in the wrong place / near Null Island. The source was not reprojected to EPSG:4326 before tiling, or its CRS was
None. Assertcrs.to_epsg() == 4326immediately before invoking tippecanoe. ogr2ogrchokes on a large GeoJSON. Convert to FlatGeobuf and stream that into tippecanoe instead of materializing a multi-gigabyte GeoJSON.- The map is slow and egress is enormous, but every tile renders. The host is not answering
Rangerequests, so each tile read pulls the whole archive. Check forAccept-Ranges: bytesand a206status on a ranged request. - The first tile loads and then nothing. CORS is configured but
Content-RangeandAccept-Rangesare not inExposeHeaders, so the shim cannot interpret the partial responses it is getting. - An inherited
.pmtilesrefuses to load with no error. It predates the v3 specification; run it throughpmtiles convertto rewrite the directory encoding. - Low zooms show a different total than high zooms. Features were coalesced without
--accumulate-attribute, so summed fields carry one member's value instead of the group's. - A file lock persists on Windows after reading an archive. The memory-mapped reader holds the handle until it is closed; open it inside a
withblock, or read into memory for small archives. - Tiles look right in
tippecanoe-decodebut wrong on the map. The problem is downstream of the pyramid — a style filter, asource-layermismatch, or an attribute type. Decoding a tile is the cleanest way to prove the build is not at fault.
Frequently Asked Questions
PMTiles or MBTiles — how do I decide without over-thinking it?
Ask whether anything has to run per request. If the answer is no — a public map, read-only data, no per-user filtering — PMTiles on a bucket is strictly simpler and cheaper, with no process to monitor, patch, or scale. If the answer is yes, the SQLite container behind an application is the honest choice, and the pattern is in Serving MBTiles with Python. Because both containers hold the identical pyramid, this decision is reversible in one pmtiles convert invocation, so it is not worth agonising over up front.
How often should tiles be rebuilt, and is there a way to update just part of the archive?
Treat tiles as a derived artifact with a refresh cadence matched to the data: nightly for an operational dataset, on-merge for anything version-controlled, on demand for reference data that changes yearly. Partial updates are possible — tile-join will merge a freshly tiled region over an existing tileset — but they introduce merge ordering as a thing you have to reason about. While a full rebuild fits inside your deploy window, rebuilding everything from the analytical store is the option with fewer ways to be subtly wrong.
What actually determines the archive's size? Maximum zoom first, by a wide margin, since each level roughly quadruples the tile count. Vertex density second — an over-detailed coastline costs far more than a large number of simple shapes. Attributes third, and this one surprises people: a dozen string fields carried at every zoom can rival the geometry's contribution, which is why whitelisting fields is a size lever and not just a hygiene measure. Feature count barely appears on that list on its own.
Can I put raster tiles in a PMTiles archive too? Yes — the container is format-agnostic, and PNG, JPEG, WebP, and AVIF raster pyramids pack into it exactly as vector tiles do, with the tile type recorded in the header. That makes it a reasonable delivery format for a pre-rendered hillshade or a satellite mosaic served alongside vector layers. It does not make it a replacement for a Cloud-Optimized GeoTIFF: rasters you intend to analyse belong in the formats described in Cloud-Native Geospatial Formats, because a tile pyramid has already thrown away the values you would want to sample.
Do vector tiles preserve enough fidelity for analysis on the client? No, and designing as if they do leads to numbers that shift with zoom. Coordinates inside a tile are quantised to an integer grid, geometry is simplified per zoom level, features may be merged or dropped to fit the size budget, and attributes can be trimmed. Tiles are a rendering artifact. Anything a user needs to measure, count, or export should be answered from the analytical store — the GeoParquet or PostGIS table the tiles were built from — with the map acting as the selection interface rather than the source of truth.
Is a tile pipeline worth it below a million features? Often, yes, but not for the reason people assume. The threshold is not feature count but the payload a first-time visitor downloads and how much of it they will ever look at. A 30 MB GeoJSON of a country's parcels is unusable on a phone regardless of how many rows it has, while 300,000 sparse points may compress into a few megabytes and be perfectly fine as a single download. Measure the compressed transfer size and the vertex total; if either is uncomfortable, tile it.