Last active
November 22, 2025 20:25
-
-
Save Maxxen/37e4a9f8595ea5e6a20c0c8fbbefe955 to your computer and use it in GitHub Desktop.
DuckDB Vector Tile Serve w/ Flask + MapLibre
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import duckdb | |
| import flask | |
| # Initialize Flask app | |
| app = flask.Flask(__name__) | |
| # Setup a global DuckDB connection with spatial extension loaded | |
| # Connect to a persistent database file with the geometry data | |
| config = {"allow_unsigned_extensions": "true"} | |
| con = duckdb.connect("tiles.db", True, config) | |
| # Install spatial from wherever you built it | |
| #con.execute("INSTALL spatial from <some path>") | |
| con.execute("load spatial") | |
| # Tile endpoint to serve vector tiles | |
| @app.route('/tiles/<int:z>/<int:x>/<int:y>.pbf') | |
| def get_tile(z, x, y): | |
| # Query to get the tile data from DuckDB | |
| # - Note that the geometry in table `t1` is assumed to be projected to `EPSG:3857` (Web Mercator) | |
| # - You may want to create an R-Tree index on the geometry column, or create a separate bounding box struct column | |
| # to perform range-filtering, or somehow pre-filter the geometries some other way before feeding them into | |
| # ST_AsMVTGeom if your dataset is large (and on disk) | |
| # Use con.cursor() to avoid threading issues with Flask | |
| with con.cursor() as local_con: | |
| tile_blob = local_con.execute(""" | |
| SELECT ST_AsMVT({ | |
| "geometry": ST_AsMVTGeom( | |
| geometry, | |
| ST_Extent(ST_TileEnvelope($1, $2, $3)) | |
| ) | |
| }) | |
| FROM t1 | |
| WHERE ST_Intersects(geometry, ST_TileEnvelope($1, $2, $3)) | |
| """, [z, x, y]).fetchone() | |
| # Send the tile data as a response | |
| tile = tile_blob[0] if tile_blob and tile_blob[0] else b'' | |
| return flask.Response(tile, mimetype='application/x-protobuf') | |
| # HTML content for the index page | |
| INDEX_HTML = """ | |
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="utf-8"> | |
| <title>Vector Tile Viewer</title> | |
| <meta name="viewport" content="initial-scale=1,maximum-scale=1,user-scalable=no"> | |
| <script src='https://unpkg.com/maplibre-gl@3.6.2/dist/maplibre-gl.js'></script> | |
| <link href='https://unpkg.com/maplibre-gl@3.6.2/dist/maplibre-gl.css' rel='stylesheet' /> | |
| <style> | |
| body { margin: 0; padding: 0; } | |
| #map { position: absolute; top: 0; bottom: 0; width: 100%; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="map"></div> | |
| <script> | |
| const map = new maplibregl.Map({ | |
| container: 'map', | |
| style: { | |
| version: 8, | |
| sources: { | |
| 'buildings': { | |
| type: 'vector', | |
| tiles: [`${window.location.origin}/tiles/{z}/{x}/{y}.pbf`], | |
| minzoom: 10 | |
| }, | |
| // Also use a public open source basemap | |
| 'osm': { | |
| type: 'raster', | |
| tiles: [ | |
| 'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png', | |
| 'https://b.tile.openstreetmap.org/{z}/{x}/{y}.png', | |
| 'https://c.tile.openstreetmap.org/{z}/{x}/{y}.png' | |
| ], | |
| tileSize: 256, | |
| minzoom: 10 | |
| } | |
| }, | |
| layers: [ | |
| { | |
| id: 'background', | |
| type: 'background', | |
| paint: { 'background-color': '#a0c8f0' } | |
| }, | |
| { | |
| id: 'osm', | |
| type: 'raster', | |
| source: 'osm', | |
| minzoom: 10, | |
| maxzoom: 19 | |
| }, | |
| { | |
| id: 'buildings-fill', | |
| type: 'fill', | |
| source: 'buildings', | |
| 'source-layer': 'layer', | |
| paint: { | |
| 'fill-color': 'blue', | |
| 'fill-opacity': 0.6, | |
| 'fill-outline-color': '#ffffff' | |
| } | |
| }, | |
| { | |
| id: 'buildings-stroke', | |
| type: 'line', | |
| source: 'buildings', | |
| 'source-layer': 'layer', | |
| paint: { | |
| 'line-color': 'black', | |
| 'line-width': 0.5 | |
| } | |
| } | |
| ] | |
| }, | |
| // Zoom in on new york | |
| center: [-74.0060, 40.7128], | |
| zoom: 12 | |
| }); | |
| map.addControl(new maplibregl.NavigationControl()); | |
| // Add click handler to show feature properties | |
| map.on('click', 'buildings-fill', (e) => { | |
| const coordinates = e.lngLat; | |
| const properties = e.features[0].properties; | |
| let popupContent = '<h3>Building Properties</h3>'; | |
| for (const [key, value] of Object.entries(properties)) { | |
| popupContent += `<p><strong>${key}:</strong> ${value}</p>`; | |
| } | |
| new maplibregl.Popup() | |
| .setLngLat(coordinates) | |
| .setHTML(popupContent) | |
| .addTo(map); | |
| }); | |
| // Change cursor on hover | |
| map.on('mouseenter', 'buildings-fill', () => { | |
| map.getCanvas().style.cursor = 'pointer'; | |
| }); | |
| map.on('mouseleave', 'buildings-fill', () => { | |
| map.getCanvas().style.cursor = ''; | |
| }); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| # Serve the static HTML file for the index page | |
| @app.route("/") | |
| def index(): | |
| return flask.Response(INDEX_HTML, mimetype='text/html') | |
| if __name__ == '__main__': | |
| # Start on localhost | |
| app.run(debug=True) |
Author
Yeah sure, but its probably going to be significantly slower instead of using a persistent DB with an rtree index, as you need to scan a lot more of the parquet file for each tile, depending on the row-group size/spatial partitioning/sorting of the file.
Any possibility to get the link to the sample
tiles.dbused here if available publicly?
Here is duckdb SQL to generate a tiles.db file (~170mb) you can use. It holds New York City buildings using data from Overture Maps Buildings.
install spatial;
load spatial;
attach 'tiles.db';
use tiles;
create or replace table t1 as (
select
-- transform into Web Mercator which the flask server expects
st_transform(geometry, 'EPSG:4326', 'EPSG:3857', always_xy := true) as geometry,
subtype,
class,
height,
from
read_parquet(
-- You may need to change to whatever release is available at
-- https://docs.overturemaps.org/guides/buildings/
's3://overturemaps-us-west-2/release/2025-11-19.0/theme=buildings/type=building/*',
filename = true,
hive_partitioning = 1
)
where
-- extent of new york city
bbox.xmin between -74.2 and -73.6 and bbox.ymin between 40.5 and 40.9
and bbox.xmax between -74.2 and -73.6
and bbox.ymax between 40.5 and 40.9
-- arbitrary filters to reduce size
and subtype is not null
and class is not null
and height is not null
);
create index my_idx on t1 using rtree (geometry);
use memory.main;
detach tiles;
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
@Maxxen, do you think it's possible to make it work by just reading the parquet file instead of using the persistent database?
UPD. It seems like I was having a data source issue. When I tried to read it directly, the app was failing with a
UserWarning: resource_tracker: There appear to be 1 leaked semaphore objects to clean up at shutdownerror at some point, but the other parquet file works just fine.