Skip to content

Instantly share code, notes, and snippets.

@Maxxen
Last active November 22, 2025 20:25
Show Gist options
  • Select an option

  • Save Maxxen/37e4a9f8595ea5e6a20c0c8fbbefe955 to your computer and use it in GitHub Desktop.

Select an option

Save Maxxen/37e4a9f8595ea5e6a20c0c8fbbefe955 to your computer and use it in GitHub Desktop.
DuckDB Vector Tile Serve w/ Flask + MapLibre
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/[email protected]/dist/maplibre-gl.js'></script>
<link href='https://unpkg.com/[email protected]/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)
@ThomasG77
Copy link

Any possibility to get the link to the sample tiles.db used here if available publicly?

@Maxxen
Copy link
Author

Maxxen commented Sep 24, 2025

Its just an extract from the overture-maps building polygons dataset stored in a duckdb database. It's about 270mb on my machine so a bit too big to share easily, but it just contains a single geometry column with a r-tree index on. You should be able to reproduce something similar to it easily.

@jutiss
Copy link

jutiss commented Sep 25, 2025

@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 shutdown error at some point, but the other parquet file works just fine.

@Maxxen
Copy link
Author

Maxxen commented Sep 26, 2025

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.

@jdefting
Copy link

Any possibility to get the link to the sample tiles.db used 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