Query GeoParquet with DuckDB: Geometry Types, CRS, and Spatial SQL
A native DuckDB workflow for GeoParquet: inspect WKB versus GEOMETRY, validate metadata, filter by extent, calculate in correct units, and verify query plans.
DuckDB can query Parquet attributes directly and, in a native build with the spatial extension, decode and analyze geometry in the same SQL pipeline. The safe workflow is: identify the file’s GeoParquet version, inspect the exposed geometry type, load spatial functions only where supported, make the CRS and units explicit, and verify the query plan before claiming a speedup.
This tutorial targets native DuckDB and the current 1.5 documentation. viewparquet’s shipped browser workbench does not currently load the spatial extension; use it for table-level GeoParquet inspection, then move spatial SQL to a pinned native environment.
Pin the environment
Start a DuckDB CLI or native client and record what actually ran:
SELECT version() AS duckdb_version;
INSTALL spatial;
LOAD spatial;
SELECT duckdb_proj_version() AS proj_version;INSTALL may download the extension the first time. In a reproducible build, pin the DuckDB client and extension-compatible version in your lockfile or container. The GEOMETRY type moved into core DuckDB in v1.5, but most analytical geometry functions remain in the spatial extension; the DuckDB geometry type documentation (opens in a new tab) explains that boundary.
Use a controlled upstream file first
Download the GeoParquet project’s official five-row example (opens in a new tab) as example.parquet. viewparquet’s catalog fully verified the file on June 12, 2026 with five rows and six columns, then performed a lighter boundary probe on August 19.
SELECT count(*) AS rows
FROM read_parquet('example.parquet');
DESCRIBE SELECT *
FROM read_parquet('example.parquet');The expected row count for that verified file is:
| rows |
|---|
| 5 |
The relevant columns are pop_est, continent, name, iso_a3, gdp_md_est, and geometry. If today’s download differs, stop and record the new object hash rather than forcing the old expected result.
Branch on the geometry type
The upstream example is useful precisely because it exposes a compatibility question. Older GeoParquet files commonly present WKB as BLOB. A reader with native Parquet geospatial logical-type support may present a newer file as GEOMETRY or GEOGRAPHY.
If DESCRIBE says BLOB, decode WKB:
WITH features AS (
SELECT
name,
pop_est,
ST_GeomFromWKB(geometry) AS geom
FROM read_parquet('example.parquet')
)
SELECT
name,
ST_GeometryType(geom) AS geometry_type,
ST_IsValid(geom) AS is_valid
FROM features
ORDER BY name;If DESCRIBE already says GEOMETRY, use the column directly:
SELECT
name,
ST_GeometryType(geometry) AS geometry_type,
ST_IsValid(geometry) AS is_valid
FROM read_parquet('native-geometry.parquet')
ORDER BY name;Applying ST_GeomFromWKB to an already decoded GEOMETRY is not a future-proof compatibility strategy. Keep the conversion in a version-specific view or ingestion adapter.
Confirm metadata separately
A geometry function succeeding does not prove GeoParquet compliance:
SELECT *
FROM parquet_kv_metadata('example.parquet')
WHERE key = 'geo';
SELECT
file_name,
name,
type,
logical_type
FROM parquet_schema('example.parquet')
WHERE name IS NOT NULL
ORDER BY name;Run gpq validate example.parquet or another GeoParquet-aware validator as described in the versioned GeoParquet guide. SQL checks and specification validation answer different questions.
Filter with an explicit envelope
For the WKB-style sample:
WITH features AS (
SELECT
name,
ST_GeomFromWKB(geometry) AS geom
FROM read_parquet('example.parquet')
)
SELECT name
FROM features
WHERE ST_Intersects(
geom,
ST_MakeEnvelope(-20, -40, 60, 40)
)
ORDER BY name;The envelope arguments are min_x, min_y, max_x, max_y. This query assumes the geometry coordinates are longitude/latitude in the same CRS as the numeric envelope. Confirm that assumption from the geo metadata; do not infer it from plausible-looking values.
For GeoParquet 2.0-style native geometry, a bounding-box predicate can be written with the extent operator:
SELECT name
FROM read_parquet('native-geometry.parquet')
WHERE geometry && ST_MakeEnvelope(-20, -40, 60, 40);Current DuckDB documentation says the && operator can use geometry statistics when available. Whether it prunes row groups depends on the file, statistics, spatial ordering, storage version, and optimizer.
Make distance and area units explicit
ST_Distance, ST_Length, and ST_Area are planar: their units are the coordinate units of the input. A result over longitude/latitude coordinates is in degrees or square degrees, not meters.
For an old WKB column known from validated metadata to be OGC:CRS84, project before calculating area:
WITH features AS (
SELECT
name,
ST_GeomFromWKB(geometry) AS geom
FROM read_parquet('example.parquet')
)
SELECT
name,
ST_Area(
ST_Transform(geom, 'OGC:CRS84', 'EPSG:6933', true)
) / 1000000.0 AS area_sq_km
FROM features
ORDER BY area_sq_km DESC;The final true requests traditional x/y axis order for the transform. EPSG:6933 is an equal-area global projection, useful for this demonstration; choose a CRS appropriate to the region and accuracy requirements of your real analysis.
DuckDB also provides spheroid functions for some types. Their documentation specifies latitude, longitude input order for POINT_2D and meter output:
SELECT ST_DWithin_Spheroid(
ST_Point2D(43.7384, 7.4246),
ST_Point2D(43.7300, 7.4200),
1000
) AS within_one_kilometre;That axis order differs from typical GeoJSON and GeoParquet WKB x/y order. Convert deliberately. Never relabel ST_DWithin over unprojected longitude/latitude as meters.
Use the aggregate function for many geometries
ST_Union is a two-geometry scalar function. To union a column, use the aggregate:
WITH features AS (
SELECT ST_GeomFromWKB(geometry) AS geom
FROM read_parquet('example.parquet')
)
SELECT ST_Union_Agg(geom) AS merged
FROM features;For a polygon coverage whose edges are known to match, review ST_CoverageUnion_Agg; for arbitrary inputs, do not substitute it without validating the coverage. Unions can be CPU- and memory-intensive even when the Parquet scan is selective.
A spatial join with a cheap prefilter
Use an extent test before an exact predicate when it preserves correctness:
SELECT
p.place_id,
a.area_id
FROM read_parquet('places.parquet') p
JOIN read_parquet('areas.parquet') a
ON p.geometry && a.geometry
AND ST_Within(p.geometry, a.geometry);This example assumes both files expose native GEOMETRY values in the same CRS. For WKB/BLOB inputs, decode in a view first. For different CRSs, transform one side before joining.
Verify performance with the plan
Do not call a query “high-performance” because it uses DuckDB or GeoParquet. Measure it:
EXPLAIN ANALYZE
SELECT count(*)
FROM read_parquet('native-geometry.parquet')
WHERE geometry && ST_MakeEnvelope(-20, -40, 60, 40);Record:
- DuckDB and spatial extension versions;
- input URL/hash and GeoParquet version;
- row groups, row count, and compressed bytes;
- whether geometry statistics exist;
- spatial ordering method;
- cold and warm runs;
- selected columns and predicate;
- hardware and storage path;
- rows returned and plan text.
Compare with a full count and an attribute-only predicate. A remote wildcard without a dated snapshot is not a reproducible benchmark because both files and schema can change.
Browser versus native DuckDB
| Capability | viewparquet today | Native DuckDB + spatial |
|---|---|---|
| Open Parquet and inspect attributes | Yes, subject to browser support | Yes |
| Show schema and raw geometry values | Yes | Yes |
| Validate GeoParquet specification | No; use GPQ/geoparquet-io | External validator |
| Load spatial extension | Not in the shipped workbench | Yes |
| ST_Intersects, transforms, spatial joins | Not promised | Yes, with spatial |
| Render a map | No | Export to a mapping layer |
Use the browser to inspect a file without setting up Python or a GIS desktop, not as a substitute for a pinned native spatial test. For delivery to a web map, continue with the MapLibre and PMTiles guide.