Generating Vector Tiles from PostGIS with Tippecanoe

Part of the Tile vs Vector Rendering Strategies guide.

Operative rule: feed tippecanoe GeoJSON in EPSG:4326 (WGS84 lon/lat) and let it handle the Web Mercator tiling internally — never pre-project your PostGIS export to EPSG:3857 meters.

How It Works

A vector tile pipeline turns a database table into a pyramid of pre-cut MVT (Mapbox Vector Tile) protobuf chunks, one per z/x/y address, that a WebGL renderer styles on the client. The pipeline has three stages: extract geometry from PostGIS as GeoJSON, tile that GeoJSON with tippecanoe, and publish the resulting archive so a map client can request MVT chunks on demand. This is the practical alternative to shipping one enormous GeoJSON blob to the browser, and it is the core reason vector rendering scales where per-feature overlays do not — a decision covered in how to choose between raster tiles and vector tiles for web dashboards.

The critical contract is the coordinate reference system. tippecanoe reads GeoJSON, and RFC 7946 mandates that GeoJSON coordinates are WGS84 longitude/latitude, i.e. EPSG:4326. tippecanoe computes the spherical Mercator tile grid itself from those degrees; the EPSG:3857 projection is an internal detail of tiling, not something you supply. If you export PostGIS geometry already transformed to EPSG:3857, the coordinates become metre values in the millions, fall far outside the valid degree range, and every tile comes out empty or clipped. This is the same axis-and-units trap described in CRS & Projection Management and in reprojecting UTM-zone data to Web Mercator with pyproj: the wire format wants degrees, the renderer does the metres.

PostGIS to Vector Tiles Pipeline Data flow: a PostGIS table is exported by ogr2ogr to EPSG:4326 GeoJSON, tippecanoe cuts a zoom pyramid into a pmtiles archive, and a MapLibre client requests MVT chunks by z/x/y address. PostGIS table geom column ST_Transform → 4326 ogr2ogr GeoJSON lon/lat degrees (EPSG:4326) tippecanoe .pmtiles zoom pyramid MVT protobuf serve MapLibre requests z/x/y GPU styling Tiling math (spherical Mercator) happens INSIDE tippecanoe Input degrees (EPSG:4326) → internal EPSG:3857 grid Pre-projecting to 3857 yourself breaks every tile

Production-Ready Implementation

The script below is a complete build step suitable for a nightly CI job. It exports a PostGIS table with ogr2ogr, forcing EPSG:4326, streams the GeoJSON straight into tippecanoe, and writes a .pmtiles archive. The -z14 maximum zoom, --drop-densest-as-needed, and --coalesce flags are the three settings that most affect output size and correctness for dashboard-scale data.

#!/usr/bin/env bash
set -euo pipefail

# --- Configuration ----------------------------------------------------------
PGCONN="PG:host=localhost dbname=gis user=gis_ro password=${PGPASSWORD}"
TABLE="public.parcels"
LAYER_NAME="parcels"          # becomes the source-layer name in the MVT
OUT_GEOJSON="build/parcels.geojson"
OUT_TILES="build/parcels.pmtiles"

mkdir -p build

# --- 1. Export PostGIS → GeoJSON in EPSG:4326 -------------------------------
# -t_srs EPSG:4326 forces WGS84 lon/lat output even if the table is stored
# in a projected SRID (e.g. a UTM zone or 3857). tippecanoe REQUIRES 4326.
# -select trims columns so only the attributes you style ship in each tile.
ogr2ogr \
  -f GeoJSON \
  -t_srs EPSG:4326 \
  -select "parcel_id,zoning,area_m2" \
  "${OUT_GEOJSON}" \
  "${PGCONN}" \
  -sql "SELECT parcel_id, zoning, area_m2, geom FROM ${TABLE}"

# --- 2. Build vector tiles with tippecanoe ---------------------------------
# -z14                      max zoom; parcels are legible by z14
# -Z6                       min zoom; below this, drop detail aggressively
# --drop-densest-as-needed  shed densest features to keep tiles < 500 KB
# --coalesce                merge adjacent features with identical attributes
# --extend-zooms-if-still-dropping  raise max zoom until nothing is dropped
# -l parcels                fixed source-layer name (else it uses the filename)
# --force                   overwrite an existing archive in CI
tippecanoe \
  -o "${OUT_TILES}" \
  -z14 -Z6 \
  --drop-densest-as-needed \
  --coalesce \
  --extend-zooms-if-still-dropping \
  -l "${LAYER_NAME}" \
  --force \
  "${OUT_GEOJSON}"

echo "Wrote ${OUT_TILES}"

The MapLibre GL JS v4 client then reads the archive through the PMTiles protocol and styles the parcels source layer entirely on the GPU:

import maplibregl from "maplibre-gl";
import { Protocol } from "pmtiles";

// Register the pmtiles:// protocol so MapLibre can range-request the archive.
const protocol = new Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile);

const map = new maplibregl.Map({
  container: "map",
  style: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json",
  center: [-73.99, 40.73], // [lng, lat] — EPSG:4326
  zoom: 11,
});

map.on("load", () => {
  map.addSource("parcels", {
    type: "vector",
    url: "pmtiles:///tiles/parcels.pmtiles",
  });

  map.addLayer({
    id: "parcels-fill",
    type: "fill",
    source: "parcels",
    "source-layer": "parcels", // must match tippecanoe -l value
    paint: {
      "fill-color": [
        "match",
        ["get", "zoning"],
        "residential", "#4c78a8",
        "commercial", "#f58518",
        "#bab0ac", // fallback
      ],
      "fill-opacity": 0.6,
    },
  });
});

Tippecanoe’s flags are all answers to one question: what should be sacrificed when a tile exceeds its byte budget? Choosing deliberately is the difference between a pyramid that thins gracefully and one that silently loses the features your dashboard is about.

What each tile-thinning strategy gives up Dropping the densest features as needed preserves the pattern but loses individual rows at low zoom. Dropping by zoom keeps whole features but removes entire classes early. Coalescing merges adjacent geometries and loses per-feature identity. Stripping attributes keeps every geometry but removes the fields popups depend on. Raising the tile size limit keeps everything and costs bandwidth and decode time. Every flag is a trade — pick which fidelity you can afford to lose strategy keeps sacrifices --drop-densest-as-needed the visual pattern and extent individual rows in dense areas -Z / -z zoom window every feature inside the window any view outside it --coalesce coverage and total area per-feature identity and picking -x attribute exclusion all geometry, unthinned the fields popups read Raising the per-tile byte limit keeps everything and moves the cost to the reader's connection — a last resort, not a default.

Alternative Variants

Emit MBTiles for a runtime tile server

If a tile server such as tileserver-gl serves tiles at runtime rather than a static .pmtiles file, change only the output extension — tippecanoe selects the container by suffix:

tippecanoe \
  -o build/parcels.mbtiles \
  -z14 -Z6 \
  --drop-densest-as-needed \
  --coalesce \
  -l parcels \
  --force \
  build/parcels.geojson

Key tippecanoe flag reference

Flag Effect When to use
-z<N> Maximum zoom the pyramid is cut to Match to the smallest feature you must resolve
-Z<N> Minimum zoom Raise to skip generating near-empty world tiles
--drop-densest-as-needed Sheds densest features to keep tiles under the size limit Dense point or parcel layers
--coalesce Merges adjacent same-attribute features Contiguous polygons (zoning, land cover)
--extend-zooms-if-still-dropping Adds zoom levels until dropping stops High-density data you must keep in full
-l <name> Fixed source-layer name Always, so the style’s source-layer is stable
-r1 / --drop-rate Controls point thinning at low zoom Very large point sets

Reading the pyramid tippecanoe just wrote

A finished archive is worth inspecting before you serve it. The shape of the tile count across zoom levels tells you immediately whether thinning kicked in where you expected, or whether an entire zoom band is nearly empty.

Tile count and size across the finished pyramid Zoom four to six holds a handful of heavily thinned tiles averaging around twelve kilobytes. Zoom seven to nine holds hundreds of tiles averaging thirty kilobytes. Zoom ten to twelve holds thousands averaging sixty kilobytes. Zoom thirteen to fourteen holds tens of thousands at close to the byte budget, where thinning stops and full detail is written. A healthy archive — count grows, average size approaches the budget z4–z6 heavy thinning ≈ 60 tiles avg 12 KB — pattern only, rows dropped z7–z9 moderate thinning ≈ 900 tiles avg 30 KB — regional shapes intact z10–z12 light thinning ≈ 14 000 tiles avg 60 KB — every parcel present z13–z14 no thinning ≈ 210 000 tiles avg 95 KB — at the byte budget

Verification Steps

After a build, confirm the archive is correct before publishing:

  • Coordinate range check — run head -c 400 build/parcels.geojson and confirm the first coordinates are small decimal degrees (e.g. -73.99), not seven-digit metre values. Seven-digit numbers mean the export escaped as EPSG:3857.
  • Tile metadata — run tippecanoe-decode build/parcels.pmtiles | head (or pmtiles show build/parcels.pmtiles) and confirm the layer name is parcels and the min/max zoom match -Z6/-z14.
  • Archive sizels -lh build/parcels.pmtiles; a runaway multi-gigabyte file usually means --drop-densest-as-needed was omitted or -z is too high.
  • Browser smoke-test — load the MapLibre page, open DevTools → Network, and confirm .pmtiles requests return HTTP 206 Partial Content (range requests working) and the fill layer renders.

Common Errors & Fixes

Tiles render empty even though the GeoJSON has features

The GeoJSON was written in EPSG:3857 (or another projected SRID), so coordinates are metre values outside the valid degree range and tippecanoe silently produces empty tiles. Fix: re-run ogr2ogr with -t_srs EPSG:4326, and confirm your PostGIS SELECT uses ST_Transform(geom, 4326) if the table is stored in a projected SRID.

Tile exceeds maximum size warnings flood the log

A dense low-zoom tile is over the 500 KB MVT soft limit and features are being truncated. Fix: add --drop-densest-as-needed so tippecanoe sheds the densest features gracefully, and --extend-zooms-if-still-dropping if you must retain every feature at higher zooms.

MapLibre shows the basemap but not the vector layer

The style’s source-layer value does not match the layer name baked into the tiles. tippecanoe defaults the layer name to the input filename unless you pass -l. Fix: set -l parcels at build time and use the identical string in the addLayer source-layer field.

pmtiles:// requests fail with a protocol error

The PMTiles protocol handler was not registered before the map initialised, or the host does not honour HTTP range requests. Fix: call maplibregl.addProtocol("pmtiles", protocol.tile) before constructing the Map, and serve the archive from storage that supports Range headers (most object storage and CDNs do).