Documentation

This guide covers serving vector tiles generated on-the-fly from SQL queries against embedded DuckDB databases — PostGIS-class spatial queries with zero infrastructure.

Why DuckDB?

DuckDB is an embedded analytical database that runs in-process — no server to install, configure, or maintain. Combined with its spatial extension, it provides PostGIS-equivalent spatial operations:

FeatureDuckDB SourcePostgreSQL/PostGIS Source
SetupZero — embedded in binaryRequires running PostgreSQL server
ConfigurationSQL query in config.tomlConnection string + credentials
Data formatsParquet, CSV, JSON, Shapefile, S3PostgreSQL tables only
Spatial opsST_Intersects, ST_Simplify, etc.Full PostGIS suite
DeploymentSingle binary, no depsRequires PostgreSQL

Quick Start

[[sources]]
id = "places"
type = "duckdb"
path = "/data/overture.duckdb"
query = """
  SELECT name, ST_AsWKB(geometry) as geometry
  FROM places
  WHERE ST_Intersects(geometry, ST_MakeEnvelope({bbox}))
"""
Info

DuckDB support requires the duckdb feature flag: cargo build --features duckdb

Configuration

Query a DuckDB Database

[[sources]]
id = "places"
type = "duckdb"
path = "/data/overture.duckdb"
query = """
  SELECT name, category, ST_AsWKB(geometry) as geometry
  FROM places
  WHERE ST_Intersects(geometry, ST_MakeEnvelope({bbox}))
"""
layer_name = "places"
minzoom = 0
maxzoom = 14

Query GeoParquet Directly (No Database File)

[[sources]]
id = "buildings"
type = "duckdb"
path = ""
query = """
  SELECT name, height, ST_AsWKB(geometry) as geometry
  FROM read_parquet('/data/overture-buildings.parquet')
  WHERE bbox.xmin <= {bbox_xmax} AND bbox.xmax >= {bbox_xmin}
    AND bbox.ymin <= {bbox_ymax} AND bbox.ymax >= {bbox_ymin}
"""
layer_name = "buildings"

Set path to an empty string for in-memory DuckDB — no .duckdb file needed.

Full Options

OptionDescriptionDefault
querySQL template with {bbox}, {z}, {x}, {y} placeholdersRequired
pathPath to .duckdb file (empty string = in-memory)Required
layer_nameMVT layer name in tile outputSource id
minzoomMinimum zoom level to serve0
maxzoomMaximum zoom level to serve22

SQL Template Variables

The query field is a SQL template. These placeholders are substituted per tile request:

VariableTypeDescriptionExample
{bbox}4 doublesTile bounding box as xmin, ymin, xmax, ymax-122.5, 37.7, -122.4, 37.8
{bbox_xmin}doubleTile bbox minimum longitude-122.5
{bbox_ymin}doubleTile bbox minimum latitude37.7
{bbox_xmax}doubleTile bbox maximum longitude-122.4
{bbox_ymax}doubleTile bbox maximum latitude37.8
{z}integerZoom level14
{x}integerTile X coordinate8192
{y}integerTile Y coordinate5461

{bbox} expands to four comma-separated values, which is exactly what ST_MakeEnvelope() expects:

ST_MakeEnvelope({bbox})
-- becomes:
ST_MakeEnvelope(-122.5, 37.7, -122.4, 37.8)

Spatial Functions

DuckDB's spatial extension provides PostGIS-equivalent functions. The extension is auto-loaded at startup.

Spatial Predicates

WHERE ST_Intersects(geometry, ST_MakeEnvelope({bbox}))
WHERE ST_Within(geometry, ST_MakeEnvelope({bbox}))
WHERE ST_Contains(boundary, ST_Point(-74.006, 40.7128))

Geometry Construction

ST_MakeEnvelope(xmin, ymin, xmax, ymax)  -- bbox → polygon
ST_Point(longitude, latitude)             -- coordinates → point
ST_GeomFromWKB(wkb_blob)                 -- WKB → geometry

Geometry Output

ST_AsWKB(geometry)   -- geometry → WKB (required for MVT encoding)

Geometry Processing

ST_Simplify(geometry, tolerance)                    -- Douglas-Peucker simplification
ST_SimplifyPreserveTopology(geometry, tolerance)     -- topology-preserving simplification
ST_Buffer(geometry, distance)                       -- buffer around geometry
ST_Centroid(geometry)                               -- centroid point
ST_Area(geometry)                                   -- polygon area

Query Patterns

Basic Spatial Filter

query = """
  SELECT name, ST_AsWKB(geometry) as geometry
  FROM places
  WHERE ST_Intersects(geometry, ST_MakeEnvelope({bbox}))
"""

Zoom-Dependent Simplification

Simplify geometries at low zoom levels for faster rendering:

query = """
  SELECT name, type,
    ST_AsWKB(
      CASE
        WHEN {z} >= 14 THEN geometry
        WHEN {z} >= 10 THEN ST_Simplify(geometry, 0.0001)
        WHEN {z} >= 6  THEN ST_Simplify(geometry, 0.001)
        ELSE                ST_Simplify(geometry, 0.01)
      END
    ) as geometry
  FROM polygons
  WHERE ST_Intersects(geometry, ST_MakeEnvelope({bbox}))
"""

Feature Filtering by Zoom

Show fewer features at low zoom levels:

query = """
  SELECT name, height, ST_AsWKB(geometry) as geometry
  FROM buildings
  WHERE ST_Intersects(geometry, ST_MakeEnvelope({bbox}))
    AND height > CASE WHEN {z} < 12 THEN 50 ELSE 0 END
"""

Multi-Table Joins

Combine data from multiple tables in a single tile:

query = """
  SELECT b.name, b.height, c.population,
         ST_AsWKB(b.geometry) as geometry
  FROM buildings b
  JOIN census c ON ST_Within(b.geometry, c.boundary)
  WHERE ST_Intersects(b.geometry, ST_MakeEnvelope({bbox}))
"""

Directory and Glob Support

Unlike the GeoParquet source (which currently takes a single file), DuckDB's read_parquet() natively supports globs and directories — no additional code needed:

-- All parquet files in a directory
query = """
  SELECT name, ST_AsWKB(geometry) as geometry
  FROM read_parquet('./data/overture/theme=places/type=place/*.parquet')
  WHERE bbox.xmin <= {bbox_xmax} AND bbox.xmax >= {bbox_xmin}
"""

-- Recursive glob across partitions
query = """
  SELECT name, ST_AsWKB(geometry) as geometry
  FROM read_parquet('./data/overture/theme=places/**/*.parquet')
  WHERE ST_Intersects(geometry, ST_MakeEnvelope({bbox}))
"""

-- Hive partitioning (auto-extracts theme/type as columns)
query = """
  SELECT name, theme, type, ST_AsWKB(geometry) as geometry
  FROM read_parquet('./data/overture/**/*.parquet', hive_partitioning=true)
  WHERE type = 'place'
    AND ST_Intersects(geometry, ST_MakeEnvelope({bbox}))
"""

This makes DuckDB the simplest way to serve tiles from large partitioned datasets like Overture Maps (483 files, 476 GB) without any preprocessing.

Query GeoParquet from S3/HTTP

DuckDB reads remote files natively:

query = """
  SELECT name, ST_AsWKB(geometry) as geometry
  FROM read_parquet('s3://overturemaps-us-west-2/release/2025-03/theme=places/**/*.parquet')
  WHERE ST_Intersects(geometry, ST_MakeEnvelope({bbox}))
"""

Aggregation per Tile

Compute statistics within each tile:

query = """
  SELECT type,
         count(*) as feature_count,
         avg(height) as avg_height,
         ST_AsWKB(ST_Centroid(ST_Collect(geometry))) as geometry
  FROM buildings
  WHERE ST_Intersects(geometry, ST_MakeEnvelope({bbox}))
  GROUP BY type
"""

Fast Filtering with Bbox Columns

For GeoParquet files with bbox struct columns (GeoParquet 1.1), combine column-level filtering with spatial predicates for best performance:

query = """
  SELECT name, ST_AsWKB(geometry) as geometry
  FROM read_parquet('/data/places.parquet')
  WHERE bbox.xmin <= {bbox_xmax} AND bbox.xmax >= {bbox_xmin}
    AND bbox.ymin <= {bbox_ymax} AND bbox.ymax >= {bbox_ymin}
    AND ST_Intersects(geometry, ST_MakeEnvelope({bbox}))
"""

The bbox pre-filter enables Parquet row group pruning before geometry evaluation — essential for performance on large files.

Geometry Column Convention

The query must return a column named geometry, geom, or wkb_geometry containing WKB-encoded geometry blobs. tileserver-rs detects these column names and uses them for MVT encoding.

Use ST_AsWKB() to convert DuckDB geometry objects to WKB:

SELECT ST_AsWKB(geometry) as geometry FROM ...

MLT Transcoding

DuckDB sources output MVT tiles. If a client requests .mlt format, the existing MLT transcoding layer converts MVT→MLT on-the-fly:

GET /data/places/14/8192/5461.mlt  →  SQL query → MVT → MLT → response

Performance

ScenarioQuery TimeNotes
Simple bbox filter, 10K features~10–50msWith spatial extension
Aggregate query (count/sum)~20–100msDuckDB excels at analytics
Multi-table join~30–150msDepends on join selectivity
Remote Parquet over S3~200–500msNetwork latency dominant
GeoParquet with bbox pre-filter~50–200msRow group pruning

Tips

  • Use ST_AsWKB() for the geometry output — DuckDB's native GEOMETRY type isn't WKB by default
  • Combine bbox column pre-filter + ST_Intersects for best performance on GeoParquet
  • Use {z} for zoom-dependent simplification and feature filtering
  • For large datasets, create a .duckdb file with spatial indexes rather than querying raw Parquet per request
  • DuckDB operations run in spawn_blocking — they don't block the async runtime