Handling CRS Loss in DuckDB Spatial Extracts
An analyst runs a proximity query against last week’s extract to count incidents within 500 metres of each shelter, gets a plausible-looking table, and puts it in the after-action briefing. The extract’s geometries are in EPSG:4326, the query assumed metres, and every “within 500 metres” is really within 500 degrees. The numbers are wrong by a factor no reviewer will spot, because the output is a count and counts always look reasonable.
Root Cause and Operational Impact
A PostGIS geometry column carries its SRID as part of the column type, which is why the operational store can refuse a mismatched insert. Nothing downstream of that column has the same guarantee, and the standard export path drops it at the first step.
Plain well-known binary has no field for a coordinate reference system. Extended WKB does, but it is not what ST_AsBinary produces, not every reader honours it, and it does not survive a round trip through most columnar writers. By the time the geometry reaches DuckDB it is a well-formed shape with no frame, and every spatial function operates on the raw numbers.
That is the whole danger: nothing errors. A geometry without a CRS is still a valid geometry, ST_Intersects still returns booleans, and ST_Distance still returns floats. The engine cannot tell that it is comparing degrees with metres, so it produces confident answers to questions nobody asked.
Tiered Resolution Strategy
- Carry the SRID as a column, not only as metadata (definitive). A dedicated
sridcolumn is data: no rewrite, repartition or copy can silently drop it, and a mixed-SRID extract becomes detectable with a singleSELECT DISTINCT. - Write it as Parquet key-value metadata as well. Redundant on purpose. Metadata is readable before scanning a row, which lets a loader reject an extract without reading it, and it survives the case where a consumer selects a subset of columns.
- Reapply the CRS at load time and refuse without it. The loader should attach the SRID explicitly and raise when neither carrier is present, rather than proceeding with an unframed geometry.
- Reproject at extract time for distance work (safe default). If the analytical workload measures distances or areas, write the extract in the incident’s projected CRS rather than in EPSG:4326, so a query that forgets to reproject is still measuring metres. This does not remove the need for the declaration; it removes the most common consequence of ignoring it.
- Assert single-SRID on every extract. A mixed-SRID extract means the operational column constraint has been relaxed upstream, which is a defect worth surfacing at the extract rather than at the query.
The recommendation to use both carriers is not belt-and-braces for its own sake — they fail in genuinely different ways. Key-value metadata is easy to omit at write time and is dropped by some tools that rewrite Parquet files, but it is free per row and readable without a scan. A column cannot be dropped by a rewrite, but it is invisible to a loader that has not read any rows yet. Carrying both means a defect in either path is caught by the other.
Production Python Implementation
from __future__ import annotations
import logging
from pathlib import Path
import duckdb
logger = logging.getLogger("incidentgis.duckdb_crs")
METADATA_KEY = "incidentgis_srid"
class MissingCRSError(RuntimeError):
"""Raised when an extract cannot state its coordinate reference system."""
def load_extract(path: Path, *, expected_srid: int) -> duckdb.DuckDBPyConnection:
"""Open a Parquet extract with its CRS verified from both carriers.
Refuses to return a usable connection unless the extract states its SRID
and that SRID is the one the caller expected. An unframed geometry is not
a degraded input — it is an input that produces confident wrong answers.
"""
con = duckdb.connect()
con.execute("INSTALL spatial; LOAD spatial;")
# Carrier one: file metadata, readable without scanning any rows.
meta = con.execute(
"SELECT value FROM parquet_kv_metadata(?) WHERE key = ?",
[str(path), METADATA_KEY],
).fetchall()
meta_srid = int(meta[0][0]) if meta else None
# Carrier two: the srid column, which no rewrite can silently drop.
column_srids = [
row[0] for row in con.execute(
"SELECT DISTINCT srid FROM read_parquet(?)", [str(path)]
).fetchall()
]
if meta_srid is None and not column_srids:
raise MissingCRSError(
f"{path} states no SRID in metadata or column — refusing to guess"
)
if len(column_srids) > 1:
raise MissingCRSError(
f"{path} spans multiple SRIDs {sorted(column_srids)}; the "
"operational column constraint has been relaxed upstream"
)
found = column_srids[0] if column_srids else meta_srid
if meta_srid is not None and column_srids and meta_srid != found:
raise MissingCRSError(
f"{path} disagrees with itself: metadata {meta_srid}, column {found}"
)
if found != expected_srid:
raise MissingCRSError(
f"{path} is EPSG:{found}, caller expected EPSG:{expected_srid}"
)
# Attach the frame explicitly so every downstream predicate is measured
# in the units the caller believes it is using.
con.execute(
"CREATE VIEW incidents AS "
"SELECT * EXCLUDE (srid), ST_GeomFromWKB(geom_wkb) AS geom "
"FROM read_parquet(?)",
[str(path)],
)
logger.info("extract_loaded", extra={
"path": str(path), "srid": found, "rows_scanned_for_srid": len(column_srids),
})
return con
Validation Checklist
- Every extract writes the SRID both as a column and as Parquet key-value metadata.
- The loader raises when neither carrier is present, rather than proceeding unframed.
- The loader raises when the two carriers disagree.
- A
SELECT DISTINCT sridassertion runs on every extract and fails on more than one value. - Extracts feeding distance or area work are written in a projected CRS, not in EPSG:4326.
- Any query returning a distance states its units in the column name, so
distance_mcannot be read as degrees. - A smoke test loads an extract with the SRID deliberately stripped and asserts the loader refuses it.
Edge Cases and Gotchas
- Extended WKB that looks like it worked. Some writers emit EWKB carrying an SRID, and some readers ignore the SRID while parsing the geometry successfully. The result passes a visual check and still arrives unframed. Do not rely on the geometry encoding to carry the frame.
- A partitioned extract where only one partition has metadata. Rewriting or repartitioning frequently preserves metadata on the first file and drops it on the rest. The column carrier is what saves this case.
- Degrees that look like metres. UTM eastings and State Plane values are large numbers, and a query written against a projected extract will produce absurd results if handed EPSG:4326 — but a query written the other way round produces small numbers that look entirely plausible. The dangerous direction is projected-expected, degrees-supplied.
ST_Distanceon geographic coordinates in DuckDB. It returns degrees, not metres, with no warning. There is no geography type to fall back on, so the projected-extract rule in tier four is doing real work.- A CRS that is correct and inappropriate. An extract in EPSG:3857 carries a valid SRID and will still misreport areas by a factor of two at temperate latitudes, exactly as the coordinate reference system standard describes. Declaring the frame is necessary and not sufficient.
Frequently Asked Questions
Why does a PostGIS geometry lose its SRID on the way to DuckDB? Because plain well-known binary has no field for a coordinate reference system. A PostGIS column enforces its SRID as part of the column type, but ST_AsBinary produces WKB, which carries only the shape. Extended WKB does include an SRID, yet it is not what ST_AsBinary emits, not every reader honours it, and it rarely survives a round trip through a columnar writer. The geometry that arrives in DuckDB is well-formed and unframed, and every spatial function then operates on raw coordinate numbers without any way to know what they mean.
What actually goes wrong when the SRID is missing? Nothing errors, which is the problem. The same pair of coordinates 1,200 units apart is about 133 kilometres if the numbers are degrees, 1.2 kilometres if they are UTM metres, and about 366 metres if they are survey feet. All three are arithmetically correct answers to ST_Distance and only one describes the ground. Without a declared frame the engine silently adopts whichever interpretation the query author assumed and reports it with full confidence, so a proximity count in an after-action briefing can be wrong by orders of magnitude while looking entirely reasonable.
Is Parquet key-value metadata enough to carry the CRS? It is necessary and not sufficient, so carry the SRID in a column as well. Metadata is free per row and readable before any scan, which lets a loader reject a bad extract cheaply, but it is easy to omit at write time and several tools drop unknown keys when rewriting or repartitioning a file. A column cannot be dropped by a rewrite and makes a mixed-SRID extract detectable with a single query, but it is invisible until rows are read. The two carriers fail in different ways, so each catches the other’s failure.
Related
- PostGIS vs DuckDB for Incident Analytics — the extract boundary this CRS discipline protects.
- Coordinate Reference Systems for Disaster Zones — why a declared frame is necessary but an inappropriate one still misreports areas.
- How to Set Up PostGIS for Emergency Response — the column constraint that makes a mixed-SRID extract a detectable upstream defect.
- Fixing Axis Order Inversion in Cross-Agency GeoJSON — the same class of failure — a valid geometry whose frame is ambiguous.
Up: PostGIS vs DuckDB for Incident Analytics