Most web maps get their data as vector tiles: features cut into a pyramid of z/x/y squares, each square encoded as a Mapbox Vector Tile. Basemaps, building footprints, places and census boundaries are all published this way, often as a single PMTiles file on object storage. The format is made for drawing, not for counting a city’s hotels, measuring its roads or joining its buildings to your own data. DuckDB’s spatial extension can open these files through GDAL with ST_Read, but it counts features that cross tile edges more than once and can’t read only the area you ask about.

vgi-mvt does both. It is a VGI worker, a separate process that adds functions and catalogs to DuckDB, and it decodes tiles into rows. Attach a tileset and its layers become tables:

SET allow_extension_repositories = 'allowed';
CREATE EXTENSION REPOSITORY IF NOT EXISTS query_farm WITH PREFIX 'https://duckdb-extensions.query.farm';
INSTALL vgi FROM query_farm;
LOAD vgi FROM query_farm;
ATTACH 'mvt' AS florence (TYPE vgi,
LOCATION 'uvx --from git+https://github.com/Query-farm/[email protected] vgi-mvt-worker',
tileset 'https://raw.githubusercontent.com/protomaps/PMTiles/main/spec/v3/protomaps(vector)ODbL_firenze.pmtiles');
SELECT "pmap:kind" AS kind, count(*) AS roads
FROM florence.roads
GROUP BY kind ORDER BY roads DESC;
kind roads
minor_road 1,907
path 1,046
medium_road 257
major_road 166
other 15
highway 8

The first four lines install the VGI extension; ATTACH starts the worker. The Protomaps sample of Florence becomes thirteen tables, from boundaries to water, read at the archive’s most detailed zoom, z15. Each attribute in the tiles becomes a column under the tileset’s own name, which is why "pmap:kind" needs quotes. The geometry column is GEOMETRY('OGC:CRS84'), so the spatial extension’s functions work on it, and a COPY to GeoParquet keeps the coordinate system.

Behind the tables are three functions you can also call directly: read_tiles reads a tileset, read_mvt reads one tile, and mvt_decode decodes tile blobs already in a query.

A tileset stores a feature once per tile it touches

A vector tile holds what falls inside its square, plus a small buffer around it so lines don’t break visibly at the seams. A road that crosses a tile edge is stored once in every tile it reaches. Decode the tiles one by one and that road comes back as several rows that overlap at each edge, so count(*), sum(ST_Length(…)) and sum(ST_Area(…)) all come out too high.

Viale Evangelista Torricelli in Florence crosses the corner where four z15 tiles meet. The tileset stores it as 4 pieces, one per tile, each extending 19 metres past its tile edge; one piece lies only in a tile's buffer. Clipping leaves 3 pieces; merging joins them into one 627 metre line. 15/17407/11945 15/17407/11946 15/17408/11945 15/17408/11946
  1. Viale Evangelista Torricelli, one road in Florence, 627 m long. Four z15 tiles meet under it.
  2. The tileset stores it once per tile it touches: 4 pieces. Each runs past its tile's edge into a 19 m buffer (4 pixels at 256), dashed here, so renderers don't clip lines at tile seams.
  3. One of the four is only a buffer copy: a few metres of road inside the margin of a tile it never enters. tile_edges := 'raw' returns all 4 as rows.
  4. tile_edges := 'clip' cuts each piece back to its own tile. The buffer copy disappears: 3 pieces, none overlapping.
  5. tile_edges := 'merge', the default, joins pieces that share a layer, id and properties where they meet: one row again. Across the whole roads layer at z14, 4,092 stored rows become 3,557, one per feature.
Real geometry: vgi-mvt reading the Protomaps Florence sample at z15 in each tile_edges mode. Web Mercator, about 730 m across.

The tile_edges argument of read_tiles decides what to do about it. 'raw' returns each tile exactly as stored. 'clip' trims every piece to its own tile, so pieces no longer overlap. 'merge', the default, also joins the pieces of each feature back into one row. Points need no joining; each is kept only in the tile that contains it.

The Florence roads layer at z14 covers 3,557 roads:

tile_edges Rows
'raw' 4,092
'clip' 3,847
'merge' 3,557

Merging relies on feature ids, so a feature without one can still come back as several rows when its pieces don’t meet. Coordinates are also rounded to each tile’s 4096 × 4096 grid, about a metre at z14. And tiles are a simplified copy of their source: lower zooms generalise and drop features. vgi-mvt reads the most detailed zoom unless you ask for another, and if you have the source data, use it.

DuckDB’s ST_Read has no such option: it returns every piece as its own row. On the same Florence file it gives 29,026 rows for 27,789 distinct building ids, and 1,642 rows for 1,384 land-use areas. It can’t read five of the thirteen layers at all, roads, places and points of interest among them. Their tags include names that differ only in case, such as name:Gsw and name:gsw, and DuckDB rejects the result with a duplicate column name error. vgi-mvt reads all thirteen.

A filter over Florence reads 273 KB of a 4.66 GB file

The Overture places sample is a single 4.66 GB PMTiles file covering the world. This query asks about one corner of one city:

SELECT category_main, count(*) AS n
FROM mvt.main.read_tiles('https://r2-public.protomaps.com/protomaps-sample-datasets/overture-pois.pmtiles', layer := 'pois')
WHERE ST_Intersects(geometry, ST_MakeEnvelope(11.24, 43.76, 11.27, 43.78))
GROUP BY ALL ORDER BY n DESC LIMIT 5;
category_main n
NULL 681
hotel 467
italian_restaurant 430
landmark_and_historical_building 284
clothing_store 200

The VGI extension passes the bounding box of the envelope to the worker, and the worker reads only the tiles under it. DuckDB then applies the exact ST_Intersects to the rows that come back. ST_Contains, ST_Within, ST_Covers, ST_CoveredBy, ST_Overlaps, ST_Touches, ST_Crosses and ST_Equals work the same way.

An 8 by 8 grid of z14 tiles around Florence with a Hilbert curve through them, and a strip showing the same tiles in file order. A query box covers 4 tiles, which sit in two places in the 4.66 GB file, 25 MB apart; vgi-mvt reads them with 3 range requests totalling 242 KB. Map: z14 tiles 8700–8707 × 5968–5975 The file 4.66 GB 25.1 MB window 1 request · 33 KB 1 request · 50 KB 1 request · 159 KB 4.269 GB4.294 GB
  1. The 64 z14 tiles around central Florence in Overture's worldwide places, a single 4.66 GB PMTiles file. Darker tiles are larger, because they hold more places.
  2. PMTiles stores tiles in Hilbert-curve order, the line through the map. The strip on the right is the same 64 tiles in file order: neighbours on the map are mostly neighbours in the file.
  3. WHERE ST_Intersects(geometry, ST_MakeEnvelope(…)) over central Florence touches 4 tiles. DuckDB passes the box to the worker, which looks up only those tiles in the archive's directory.
  4. In the file they sit in two places, 25 MB apart: the box straddles the boundary between two quarters of the curve.
  5. Tiles close together in the file are fetched together: 3 range requests, 242 KB. With 32 KB of directory, the query reads 0.006% of the archive.
Real tile ids, sizes and offsets from the Overture POI sample archive, and the requests vgi-mvt sent for the box 11.24, 43.76, 11.27, 43.78. Strip heights are proportional to tile size; breaks mark other tiles in between.

The query took 0.79 s, the median of five runs on an Apple M3 Mac, each with a fresh worker and no cached tiles. The worker made six HTTP range requests: three for the archive’s header and directories, 31.6 KB, and three for the tiles, 241.6 KB. The same query through ST_Read, which has no way to take the box, hadn’t finished after 15 minutes.

PMTiles stores tiles in the order of a Hilbert curve, which keeps nearby tiles close together in the file, so a small box usually needs one or two reads. This box straddles a boundary between quarters of the curve, which is why two of its tiles sit 25 MB away from the other two.

The filter has to be a constant geometry. A box that comes from a join, or that sits under an OR or a NOT, isn’t passed to the worker. Those queries read the whole zoom level instead, up to a limit of 10,000 tiles by default. If that happens, pass the box yourself, as the bbox option when you attach or as arguments to read_tiles.

Writing a tileset back is one COPY

The worker also adds a COPY format. Each row becomes one feature, and a layer column says which layer it goes in, so one statement writes a whole tileset. This one rebuilds nine layers of the Florence sample:

COPY (
SELECT layer, feature_id, properties, geometry,
CASE layer WHEN 'buildings' THEN 13 WHEN 'pois' THEN 14 END AS minz
FROM mvt.main.read_tiles('…/protomaps(vector)ODbL_firenze.pmtiles', zoom := 15)
WHERE list_contains(['earth', 'landuse', 'natural', 'water', 'roads',
'transit', 'buildings', 'pois', 'places'], layer)
ORDER BY list_position(['earth', 'landuse', 'natural', 'water', 'roads',
'transit', 'buildings', 'pois', 'places'], layer)
) TO 'florence.pmtiles' (FORMAT 'mvt.pmtiles', minzoom 10, maxzoom 15, minzoom_column 'minz',
name 'Florence', attribution '© OpenStreetMap contributors, Protomaps');

feature_id becomes each feature’s id. properties is the JSON object of attributes that read_tiles returns, and it is expanded back into separate attributes; any other column becomes an attribute as it is. minz holds buildings back until z13 and pois until z14, which keeps the low-zoom tiles small. The ORDER BY sets the drawing order within each tile.

A table of nine layers with the number of features each contributes to one z15 tile of Florence, and the tile itself: its layers shown as separate sheets, then stacked, then as a flat map of the Arno, the Ponte Vecchio, streets and 1,370 buildings. layer rows in this tile earth 1 landuse 78 natural 31 water 4 roads 319 transit 2 buildings 1,370 pois 1,027 places 2 total 2,834 tile 15/17408/11944
  1. One COPY takes 42,695 rows from nine Florence layers. Each row has a layer, an id, attributes and a geometry. This z15 tile around the Ponte Vecchio gets 2,834 of them.
  2. Each layer becomes its own sheet of the tile: features clipped to the tile plus its buffer, simplified for the zoom, and snapped to the tile's 4096-unit grid.
  3. The sheets stack in the order the rows arrived, so the query's ORDER BY decides what draws on top: buildings over roads, places over everything.
  4. One tile: 9 layers, 2,834 features, 74 KB gzipped. The archive holds 123 tiles like it from z10 to z15, 3.6 MB in all.
Real tile: z15/17408/11944 of the tileset written by the COPY below, decoded layer by layer. 133 KB decoded, 74 KB as stored.

For each zoom level, the worker simplifies every line and polygon once, so neighbouring tiles agree where they meet. It then cuts each feature into the tiles it reaches, including their buffers, and snaps the pieces to the tile grid, repairing any polygon the cut or the snap made invalid. Identical tiles are stored once, and the tiles are written in Hilbert order, so the result reads as cheaply as the archive above.

The statement wrote 42,695 features into 123 tiles, 3.6 MB, in 12.8 s, including reading the sample over HTTP. MapLibre draws the result like any other PMTiles basemap:

A street map of central Florence rendered by MapLibre from the tileset written by COPY: the Arno in blue with its bridges, the Duomo and Piazza della Signoria among dense buildings, white streets, green parks south of the river, and red dots for thousands of places.
The written florence.pmtiles in MapLibre GL 4.7 at z15.4. We wrote the style; every tile came from the COPY.

The writer doesn’t drop features to keep tiles small: every feature appears at every zoom in its range. If a compressed tile grows past max_tile_bytes (500 KB by default), the COPY stops with an error that names the tile and suggests raising minzoom, setting minzoom_column, or simplifying more. Tippecanoe and planetiler thin features automatically, which is what a planet-scale basemap needs. The writer is Python, with the geometry work done on whole arrays at a time: a million random points took 70 s to tile up to z10 and 85 s up to z12. That is comfortable for a city or a country.

Code and measurement details

Item Detail
vgi-mvt v0.2.2, commit a9ad527
DuckDB 2.0.0 (Python package), VGI extension from the Query Farm repository
Machine Apple M3 Mac; remote archives read over the internet
Data Protomaps Florence sample (ODbL); Overture places sample, 4,656,603,871 bytes
Florence query Median of five runs, each with a fresh worker process; request counts from instrumenting the worker’s HTTP reader
ST_Read comparison DuckDB 2.0.0 spatial extension (a3dbcd1781), Florence sample over /vsicurl/; distinct ids from GDAL’s mvt_id column; the Overture query run once through ST_Read over /vsicurl/ and stopped at 15 minutes
Points benchmark 1,000,000 random points over 30° × 25°, max_tile_bytes 0 (no limit), vgi-mvt 0.2.0
Figures Every coordinate, count and byte offset comes from the worker; the export script is in this site’s repository at scripts/vector-tiles-blog/export.py

The README documents every function and every ATTACH and COPY option. If something doesn’t work as described, open an issue.