Rendering store pins on a pan-and-zoom map means answering thousands of tile requests a second, and each one is fundamentally a rectangle query — which is why it belongs under bounding box filtering. The scenario: a stores table typed geometry(Point, 4326) feeds a web map that requests XYZ tiles as /{z}/{x}/{y}. For every tile you convert its slippy-map coordinates into a bounding box, use the && operator against a GiST index to grab only the stores inside it, then clip and encode the result as a Mapbox Vector Tile. The whole path has to stay index-bound, because a single map view fires a dozen tile requests at once.
Problem Statement
The map client speaks in tile coordinates: zoom z, column x, row y, addressing the Web Mercator (SRID 3857) grid. The database stores store points in EPSG:4326. The task is to translate a tile address into a rectangle, select the handful of stores that fall in it out of millions, and return them — ideally as a compact binary vector tile rather than JSON. The failure everyone hits is subtle: it is easy to write a query that produces the right stores but wraps the indexed geometry column in a per-row function, so the GiST index is ignored and every tile scans the whole table. At tile-request volume that is fatal.
Why the Naive Approach Fails
The instinct is to reproject the geometry column into Web Mercator so it matches the tile envelope:
-- Anti-pattern: ST_Transform wraps the indexed column, so the GiST index is unusable
SELECT id, name
FROM stores
WHERE ST_Transform(geom, 3857) && ST_TileEnvelope(12, 1205, 1539);This returns the correct rows and is completely unindexable. ST_Transform(geom, 3857) is a function of the column, evaluated freshly for every one of the millions of rows, so the planner cannot consult the GiST index built on geom — it must reproject the entire table and test each result. EXPLAIN shows a Seq Scan, and tile latency runs into seconds.
A second common miss is computing the tile bounds in Python with hand-rolled Web Mercator math, getting the y-axis flip or the earth-radius constant slightly wrong, and shifting every tile by a few pixels. And a third is returning raw GeoJSON for thousands of rows per tile, saturating the network with coordinates the client immediately discards outside the tile frame. The right approach flips the transform onto the small constant envelope, keeps the column untouched, and clips server-side.
Production-Ready Implementation
Transform the tile envelope — a single small rectangle — into the column’s SRID so the && comparison runs against a constant the GiST index can use. Then clip with ST_AsMVTGeom and encode with ST_AsMVT.
CREATE INDEX IF NOT EXISTS idx_stores_geom_gist
ON stores USING GIST (geom);
ANALYZE stores;import psycopg
DSN = "postgresql://tiles:secret@10.0.4.9:5432/retail"
# One query serves a whole tile. ST_TileEnvelope builds the 3857 tile bounds;
# ST_Transform(..., 4326) reprojects that *envelope* (not the column) so the
# && prefilter hits the GiST index on stores(geom). ST_AsMVTGeom clips each
# point to tile-local coordinates; ST_AsMVT packs the layer into MVT bytes.
TILE_SQL = """
WITH bounds AS (
SELECT ST_TileEnvelope(%(z)s, %(x)s, %(y)s) AS merc,
ST_Transform(ST_TileEnvelope(%(z)s, %(x)s, %(y)s), 4326) AS geo
),
mvt AS (
SELECT
s.id,
s.name,
ST_AsMVTGeom(
ST_Transform(s.geom, 3857), -- transform only the surviving rows
bounds.merc,
4096, -- tile extent in integer units
64, -- edge buffer in tile units
true -- clip to the tile
) AS geom
FROM stores s, bounds
WHERE s.geom && bounds.geo -- index-usable bbox prefilter (4326)
)
SELECT ST_AsMVT(mvt, 'stores', 4096, 'geom') AS tile
FROM mvt
WHERE geom IS NOT NULL;
"""
def render_tile(z: int, x: int, y: int) -> bytes:
"""
Return the Mapbox Vector Tile bytes for the 'stores' layer at z/x/y.
stores.geom is SRID 4326 with a GiST index. The && prefilter uses the
tile envelope reprojected to 4326; only matching rows are transformed
to 3857 for MVT encoding. Parameters are bound, never string-formatted.
"""
with psycopg.connect(DSN) as conn:
with conn.cursor() as cur:
cur.execute(TILE_SQL, {"z": z, "x": x, "y": y})
row = cur.fetchone()
return bytes(row[0]) if row and row[0] is not None else b""The key move is WHERE s.geom && bounds.geo, where bounds.geo is the tile envelope reprojected once to SRID 4326. The comparison is between the indexed column and a constant geometry, so the GiST index returns only the stores whose bounding box overlaps the tile — typically a few dozen rows. Only those survivors are then reprojected to 3857 inside ST_AsMVTGeom, which clips them to the tile, applies a 64-unit edge buffer so pins near the seam are not cut awkwardly, and maps them into the 4096-unit tile coordinate space. ST_AsMVT aggregates the clipped features into the binary layer the map client consumes directly.
A thin web layer turns this into a tile endpoint:
from fastapi import FastAPI, Response
app = FastAPI()
@app.get("/tiles/stores/{z}/{x}/{y}.mvt")
def stores_tile(z: int, x: int, y: int) -> Response:
data = render_tile(z, x, y)
return Response(
content=data,
media_type="application/vnd.mapbox-vector-tile",
headers={"Cache-Control": "public, max-age=3600"},
)The same pattern serves delivery-zone polygons: swap the layer name and let ST_AsMVTGeom clip each delivery_zones polygon to the tile boundary, which is exactly what makes a polygon spanning several tiles render seamlessly.
Configuration and Tuning Knobs
| Setting | Recommended value | Effect on tile serving |
|---|---|---|
extent (ST_AsMVTGeom / ST_AsMVT) |
4096 | Tile-local coordinate resolution. 4096 is the vector-tile standard; smaller values shrink tiles at the cost of precision. |
buffer (ST_AsMVTGeom) |
64 | Edge margin in tile units so features at the seam are not clipped mid-symbol across adjacent tiles. |
random_page_cost |
1.1 (SSD) | Keeps the && prefilter on the GiST index rather than a sequential scan. |
statement_timeout |
2s–5s | Caps a runaway low-zoom tile that would otherwise scan a huge area; return an empty tile rather than block the pool. |
| server-side simplification | per zoom level | At low zoom, wrap point selection with a de-clutter or use ST_SnapToGrid on polygons so dense areas do not ship thousands of features per tile. |
At low zoom levels a single tile covers a large area and can match a very large number of stores. Either cap density with a LIMIT inside the mvt CTE, aggregate to clusters, or refuse to serve individual points below a zoom threshold and switch to a heat/cluster layer. The optimizing bounding box queries with the && operator page covers the index mechanics that make the prefilter cheap.
Verification Steps
1. Confirm the prefilter uses the GiST index, not a sequential scan:
EXPLAIN (ANALYZE, BUFFERS)
SELECT s.id
FROM stores s
WHERE s.geom && ST_Transform(ST_TileEnvelope(12, 1205, 1539), 4326);The plan must show Index Scan using idx_stores_geom_gist or a Bitmap Index Scan on it. If it shows Seq Scan, you have almost certainly transformed the column instead of the envelope somewhere in the query.
2. Assert the endpoint returns non-empty bytes over a populated area and empty bytes over the ocean:
def verify_tile_endpoint():
populated = render_tile(12, 1205, 1539) # over a city with stores
assert len(populated) > 0, "expected features in a populated tile"
empty = render_tile(4, 0, 0) # far corner, no stores
assert isinstance(empty, (bytes, bytearray)), "tile must return bytes"
print(f"populated tile: {len(populated)} bytes; empty tile OK")3. Decode a returned tile with a library such as mapbox-vector-tile in a test to confirm feature counts and that clipped geometries sit inside the [0, 4096] tile coordinate range.
Gotchas Checklist
-
Never wrap the indexed column in
ST_Transform.ST_Transform(geom, 3857) && envelopereprojects every row and ignores the GiST index. Reproject the tile envelope to the column’s SRID instead and comparegeom && transformed_envelopeso the index engages. -
ST_TileEnvelopereturns SRID 3857. It is defined for the standard Web Mercator XYZ scheme. If your column is EPSG:4326, reproject the envelope to 4326 for the&&prefilter, then transform the surviving rows to 3857 forST_AsMVTGeom. Mixing the SRIDs in the&&comparison silently matches nothing. -
Low-zoom tiles can match the whole table. A
z=0tile covers the planet. Guard with a per-tileLIMIT, a zoom threshold, or server-side clustering, and setstatement_timeoutso a pathological request cannot pin a connection. -
Set the edge
bufferor symbols clip at seams. Withbuffer=0, a store icon straddling a tile boundary is sliced in half and the client cannot draw it cleanly. A 64-unit buffer lets adjacent tiles overlap enough to render edge features whole. -
Cache the bytes. Tiles for the same
z/x/yare identical until the underlying stores change. AddCache-Controland, ideally, a CDN orpg_stat_statements-monitored materialised tile cache. See advanced GiST indexing and optimization for keeping the index healthy under continuous store edits.
Frequently Asked Questions
Should I transform the tile envelope or the geometry column?
Transform the envelope, never the column. Wrapping the indexed geom in ST_Transform on every row forces the planner to evaluate the function per row and defeats the GiST index. Reproject the single small tile envelope to the column’s SRID instead, so the && comparison runs against a constant geometry the index can use.
What is the difference between ST_TileEnvelope and ST_MakeEnvelope?
ST_TileEnvelope(z, x, y) computes the Web Mercator (SRID 3857) bounds of an XYZ tile directly from its coordinates, which is exactly what a slippy-map client requests. ST_MakeEnvelope(xmin, ymin, xmax, ymax, srid) builds an arbitrary rectangle from explicit corners. Use ST_TileEnvelope for standard tile schemes and ST_MakeEnvelope when you already hold raw bounds.
Why does clipping with ST_AsMVTGeom matter?
A store point near a tile edge, or a delivery-zone polygon that spans several tiles, must be cut to the tile grid and translated into tile-local integer coordinates. ST_AsMVTGeom clips to the tile envelope, applies an edge buffer so features are not sliced awkwardly at the seam, and produces the coordinate space that ST_AsMVT encodes into the vector tile.
Related Topics
- Bounding Box Filtering — parent topic: envelope predicates and index-backed rectangle queries
- Optimizing Bounding Box Queries with the
&&Operator — the index mechanics behind the tile prefilter - Spatial Joins — clip delivery-zone polygons against the same tile envelope
- Advanced GiST Indexing & Optimization — keeping the store index fast under continuous tile traffic