Serving Vector Tiles from PostGIS with ST_AsMVT

ST_AsMVT aggregates rows into a single MVT tile, and ST_AsMVTGeom clips and transforms each geometry into the tile’s local grid. Together they turn a spatial table into a tile endpoint with no build step — which is the right architecture only when the data genuinely changes faster than a build can run.

When to Use This

When tiles must reflect the database within seconds: incident feeds, vehicle positions, live editing sessions, anything with a “last updated” that readers care about. Below that cadence, a built archive serves the same bytes without a database on the request path.

The honest test is whether a reader would notice a five-minute-old tile. If not, generate ahead of time.

Specification Detail

Function Role
ST_TileEnvelope(z, x, y) The tile’s bounds in EPSG:3857
ST_AsMVTGeom(geom, bounds, extent, buffer, clip) Clips and transforms one geometry into tile-local units
ST_AsMVT(rows, layer_name, extent, geom_column) Aggregates rows into one MVT layer
Parameter Typical value Notes
extent 4096 Must match what the client expects
buffer 64 Tile-local units; prevents seams on stroked geometry
clip_geom true Clipping is what keeps a tile self-contained
What the tile query does in one passThe tile envelope is computed, rows are selected by a spatial index using that envelope, each geometry is clipped and transformed into tile units, and the rows are aggregated into one MVT blob.QUERY SHAPETile envelopeST_TileEnvelopeIndex lookup&& on the geometryGiST index, or the query isa seq scanClip + transformST_AsMVTGeomAggregateST_AsMVT
The index lookup is the only step whose cost depends on table size. Everything after it works on the tile's rows, which is why the query is fast when the index is right.

Production Command

sql
CREATE OR REPLACE FUNCTION public.incidents_tile(z integer, x integer, y integer)
RETURNS bytea
LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE
AS $$
DECLARE
  bounds geometry := ST_TileEnvelope(z, x, y);
  mvt bytea;
BEGIN
  SELECT INTO mvt ST_AsMVT(t, 'incidents', 4096, 'geom')
  FROM (
    SELECT
      ST_AsMVTGeom(
        ST_Transform(i.geom, 3857),   -- source is EPSG:4326
        bounds, 4096, 64, true
      ) AS geom,
      i.id,
      i.category,
      i.severity,
      to_char(i.reported_at, 'YYYY-MM-DD"T"HH24:MI:SSZ') AS reported_at
    FROM incidents i
    WHERE i.geom && ST_Transform(bounds, 4326)   -- index-usable, on the source SRID
      AND i.reported_at > now() - interval '7 days'
      AND (z >= 12 OR i.severity >= 3)           -- thin low zooms deliberately
    LIMIT 20000
  ) AS t;

  RETURN COALESCE(mvt, ''::bytea);
END;
$$;

Four details in that function are what separate a working endpoint from a slow one.

The && predicate is written against the source SRID so the GiST index on i.geom can serve it. Transforming the column instead of the envelope — ST_Transform(i.geom, 3857) && bounds — makes the index unusable and turns every tile into a sequential scan.

The zoom-dependent WHERE clause is how a live endpoint stays inside the tile budget without a dropping strategy. There is no --drop-densest-as-needed here; thinning is something the query has to do.

The LIMIT is a safety valve. Without it, one dense tile can return a million rows and produce a tile no client will render.

And COALESCE turns a null aggregate into an empty tile, because ST_AsMVT over zero rows returns NULL and a handler that passes that through will emit a 500 for every empty area.

Indexing and Simplification

sql
-- Required: without this every tile is a sequential scan
CREATE INDEX incidents_geom_gist ON incidents USING GIST (geom);

-- Usually worth it: filter columns used in the tile query
CREATE INDEX incidents_reported_at ON incidents (reported_at DESC);

-- For polygon layers, simplify per zoom rather than shipping full geometry
ST_AsMVTGeom(
  ST_Simplify(ST_Transform(g.geom, 3857), 4 * (2 ^ (14 - z))),
  bounds, 4096, 64, true
)

The simplification tolerance halves with each zoom because the tile’s ground extent does — the same scaling relationship Tippecanoe applies automatically, which a live endpoint has to implement itself.

What each optimisation is worthQuery latency at the 95th percentile for a naive query, with a GiST index, with the envelope transformed instead of the column, and with simplification added.QUERY LATENCYms, p95 at z14No spatial index4,200 msGiST index, transform on the column3,900 msGiST index, transform on the envelope86 ms+ per-zoom simplification41 ms
The index is not an optimisation, it is a precondition. The third row is the same query with the transform on the wrong side — slower than having no index at all is not possible, but it comes close.
Where a live tile request stops once a cache is in frontA request reaches the CDN, misses only when the short TTL has expired, passes through the pooler to PostgreSQL, and returns through the same path to be cached again.LIVE PATHBrowserz/x/y requestCDN30 s TTLstops almost everyrequestPoolertransaction modemandatory at tile requestratesPostGISST_AsMVT
A thirty-second TTL is indistinguishable from live for most readers and removes 99% of the database load.

Interaction Effects

With the edge cache. A live endpoint still belongs behind a CDN, with a short deliberate TTL — thirty to sixty seconds is usually indistinguishable from live and removes almost all database load. Without it, every reader’s every pan is a query, and the database becomes the capacity limit for the whole map.

With connection pooling. Tile requests are short and numerous, which is the worst shape for PostgreSQL connections. A pooler in transaction mode is effectively mandatory; without one, pool exhaustion appears as latency rather than as errors, which makes it hard to diagnose.

With Martin. Martin calls exactly this kind of function and handles the HTTP layer, headers and TileJSON. Writing the function and letting Martin serve it is the usual arrangement — see tile server selection.

With gzip. ST_AsMVT returns uncompressed bytes. The serving layer must compress and set Content-Encoding, and must not do so twice — the double-gzip failure is common here because the compression is added by hand.

Performance Impact

Configuration p50 p95 Queries per tile
No cache 38 ms 86 ms 1
30 s edge TTL, steady traffic 38 ms 86 ms ~0.01
30 s edge TTL, cold 38 ms 86 ms 1

The point of that table is that the query latency does not change — the number of queries does, by two orders of magnitude. Tuning the query matters, and putting a cache in front of it matters more.

Common Mistakes

Transforming the geometry column in the predicate. Disables the index. This is the single most common cause of a slow tile endpoint.

Returning NULL for an empty tile. Produces a 500 for every tile over water.

No LIMIT. One dense tile can return enough rows to exhaust memory and produce an unusable tile.

No cache in front. The database becomes the scaling limit for a workload that is 99% cacheable even at a thirty-second TTL.

Forgetting per-zoom thinning. Live endpoints have no dropping strategy; low zooms will return every row unless the query excludes them.

FAQ

Is ST_AsMVT fast enough for production?

Yes, with the right index and a cache in front. Sub-hundred-millisecond p95 for a typical urban tile is achievable, and the cache means the database sees a small fraction of reader traffic.

Can I serve several layers from one query?

Yes — ST_AsMVT produces one layer, and concatenating several calls’ output produces a multi-layer tile. Martin does this when a function returns several layers, and it is cheaper than several requests.

Should the function be marked IMMUTABLE?

It is stable rather than truly immutable, since the table changes. Marking it IMMUTABLE allows better planning and is conventional for tile functions; STABLE is the strictly correct choice if the distinction matters to your deployment.

How do I know when to switch to pre-built tiles?

When the cache TTL you can accept exceeds the time a rebuild takes. At that point the build produces the same freshness with no database on the request path.