Querying Partitioned GeoParquet with DuckDB Spatial

DuckDB reads a directory of Hive-partitioned GeoParquet files as one table, prunes partitions from the path, skips row groups from their statistics, and projects only the columns you name — then writes NDJSON on stdout that Tippecanoe consumes directly. For a partitioned tile build this replaces an extraction script, an intermediate file, and most of the memory.

When to Use This

When the source is a partitioned GeoParquet dataset — region=.../year=.../part-*.parquet — and each tile build needs a slice of it. That is the common shape for anything derived from a data warehouse, and it is exactly the shape a partitioned tile build wants to consume.

For a single unpartitioned file, pyarrow with a column projection is simpler and just as fast.

Specification Detail

Capability Syntax Effect
Read a partitioned tree read_parquet('data/**/*.parquet', hive_partitioning=true) Partition columns become queryable columns
Partition pruning WHERE region = 'south' Whole directories never opened
Row-group skipping WHERE bbox_xmin > … Groups excluded by their statistics
Column projection SELECT name, geom Other column chunks never read
Spatial predicates ST_Intersects(geom, ST_MakeEnvelope(…)) Needs the spatial extension
GeoJSON output ST_AsGeoJSON(geom) The shape Tippecanoe’s stdin wants

The three pruning mechanisms compose, and their order of effectiveness is the order above: skipping a directory is free, skipping a row group costs reading statistics, and skipping a column costs nothing once the projection is declared.

Three independent ways the scan avoids reading bytesPartition pruning removes directories, row-group statistics remove groups within a file, and column projection removes column chunks within a group.THREE PRUNINGSPartition pruningdirectory levelA predicate on a Hive partition columnexcludes whole directories without opening afile.Row-group skippingwithin a fileMin and max statistics per column let thereader skip groups whose range cannot match.Column projectionwithin a groupOnly the projected column chunks are readand decompressed.What remainsthe actual readThe geometry column of the surviving groups— usually most of what is left.
They multiply. A query using all three on a well-organised dataset can read under one percent of the bytes a naive scan would.

Production Command

sql
-- extract.sql
INSTALL spatial; LOAD spatial;

COPY (
  SELECT
    'Feature'                                  AS type,
    json(ST_AsGeoJSON(geometry))               AS geometry,
    json_object(
      'name',    name,
      'highway', highway,
      'lanes',   CAST(lanes AS INTEGER)
    )                                          AS properties
  FROM read_parquet('s3://warehouse/roads/**/*.parquet', hive_partitioning = true)
  WHERE country = 'GB'                          -- partition pruning
    AND bbox.xmin < 0.34 AND bbox.xmax > -0.52  -- row-group skipping
    AND bbox.ymin < 51.70 AND bbox.ymax > 51.27
    AND geometry IS NOT NULL
) TO '/dev/stdout' (FORMAT JSON, ARRAY false);
bash
duckdb -c ".read extract.sql" \
  | tippecanoe -o build/roads.mbtiles -l roads -Z5 -z14 --force \
      --drop-densest-as-needed --simplification=8 -

FORMAT JSON, ARRAY false is what produces newline-delimited objects rather than a JSON array — exactly the NDJSON shape Tippecanoe reads on stdin. An array would be a single enormous value and Tippecanoe would reject it.

The bbox predicates use the bounding-box columns GeoParquet 1.1 encourages writers to emit. When they are absent, ST_Intersects still works and does not skip row groups, so the scan reads everything and filters afterwards:

sql
-- Correct, but no row-group skipping without bbox columns
WHERE ST_Intersects(geometry, ST_MakeEnvelope(-0.52, 51.27, 0.34, 51.70))
What each pruning mechanism removesBytes read for the same extract with no pruning, with partition pruning only, with partition pruning and column projection, and with all three mechanisms.BYTES READGB read for one city-sized extractNo pruning214 GBPartition pruning only18 GB+ column projection4.1 GB+ row-group skipping1.3 GB
The last two rows are nearly the same because, once columns are projected, the geometry column is almost everything that is left.

Driving a Partitioned Build

Because the extract is a query, generating one per tile partition is a loop rather than a script:

bash
while IFS=$'\t' read -r NAME W S E N; do
  duckdb -c "
    INSTALL spatial; LOAD spatial;
    COPY (
      SELECT 'Feature' AS type,
             json(ST_AsGeoJSON(geometry)) AS geometry,
             json_object('name', name, 'highway', highway) AS properties
      FROM read_parquet('warehouse/roads/**/*.parquet', hive_partitioning = true)
      WHERE bbox.xmin < $E AND bbox.xmax > $W
        AND bbox.ymin < $N AND bbox.ymax > $S
    ) TO '/dev/stdout' (FORMAT JSON, ARRAY false);" \
  | tippecanoe -o "build/$NAME.mbtiles" -l roads -Z5 -z14 --force --quiet -
done < partitions.tsv

Each partition’s scan reads only the row groups its bounding box touches, which on a spatially ordered dataset is a small fraction of the file — and that is what makes a per-partition extract cheap enough to run on every build.

Warehouse to tileset without an intermediate fileDuckDB reads the partitioned dataset with pruning applied, emits NDJSON on stdout, and Tippecanoe consumes it from stdin, with no file written between them.ONE PIPEPartitionedParquethive layouton object storageDuckDBprune + projectST_AsGeoJSONNDJSON on stdoutone Feature per linetippecanoereads stdin
Nothing touches disk between the warehouse and the tiler. Back-pressure from Tippecanoe throttles the query for free, so peak memory is bounded by the row groups in flight.

Interaction Effects

With CRS. DuckDB’s spatial extension does not reproject implicitly. If the dataset is not in EPSG:4326, wrap the geometry in ST_Transform with an explicit source and target, or Tippecanoe receives coordinates it will interpret as degrees.

With file ordering. Row-group skipping is only effective when the data is spatially clustered — see bbox spatial filtering for how much ordering is worth.

With attribute types. json_object produces the properties object, and its values carry DuckDB’s types. A NULL becomes JSON null, which Tippecanoe stores as an absent attribute — usually what you want, and worth knowing when a style expects the key to exist.

With memory. DuckDB streams the COPY output rather than materialising the result, so peak memory is bounded by the row groups in flight rather than by the result size. This is what lets a multi-hundred-gigabyte source feed a build on a modest machine.

Performance Impact

Measured against a 340-column, 210 GB partitioned road dataset on local NVMe:

Query Bytes read Wall clock Peak RAM
Whole dataset, all columns 214 GB 41 min 6.2 GB
One country, all columns 18 GB 4 m 20 s 5.8 GB
One country, 4 columns 4.1 GB 1 m 05 s 1.1 GB
One city bbox, 4 columns 1.3 GB 22 s 0.6 GB

The memory column is as interesting as the time: projection reduces peak RAM by more than it reduces bytes read, because the columns not projected are never decompressed into memory at all.

Common Mistakes

Emitting a JSON array. Without ARRAY false the output is one giant array and Tippecanoe rejects it with a FeatureCollection error.

Assuming ST_Intersects prunes row groups. It filters correctly and reads everything. Use the bbox columns for the coarse filter and a spatial predicate only for exactness if needed.

Forgetting LOAD spatial. INSTALL is persistent, LOAD is per session, and a script that installs but does not load fails on the first ST_ call.

Leaving the geometry column out of the projection. Easy to do when listing attributes, and it produces a stream of features with no geometry that Tippecanoe accepts and tiles as nothing.

FAQ

Does DuckDB read GeoParquet metadata?

The spatial extension understands the geo metadata for reading geometry columns. It does not reproject based on the declared CRS, so confirm the CRS separately and transform explicitly.

Can it read directly from object storage?

Yes, with the httpfs extension, and partition pruning works against remote paths too — which means a build machine never has to hold the dataset locally.

Is this faster than pyarrow?

For partitioned datasets with predicates, usually yes, because the SQL layer pushes filters into the scan automatically. For a single file with a simple column projection the two are comparable.

What about very wide attribute tables?

Project deliberately. The saving in this page’s measurements comes mostly from projection, and a SELECT * on a 340-column table undoes it entirely.