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,
    },
  });
});

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

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).