Handling Null and Empty Geometries
A null geometry and an empty geometry are different things, they behave differently in every predicate, and neither of them is invalid — which is why a validity gate lets both straight through. This walkthrough sits under geometry validity and repair and isolates the third state: where nulls and empties come from, what each one does to a join, an aggregate and an index, and how to decide their fate explicitly rather than discovering it in a total that will not reconcile.
Root-Cause Analysis: three states, not two
Most code is written as though geometry is either present or invalid. There are three states, and the two that are not “present” behave in opposite ways.
- NULL geometry. The column has no value at all. Every predicate involving it evaluates to
NULL, which in aWHEREclause is not true, so the row silently fails every filter. In an inner join it disappears; in an aggregate it is skipped bycount(geom)but counted bycount(*). - EMPTY geometry. The column has a value — a well-formed
POLYGON EMPTYorGEOMETRYCOLLECTION EMPTY— that describes no space. It is valid:ST_IsValidreturns true. Predicates against it return false rather than null, so the row survives aWHERE NOT ST_Intersects(...)that a null row would fail. - Present geometry. Everything else, valid or not.
The two sources are equally common and quite different. Nulls arrive from left joins, from optional fields in a source format, and from failed conversions where a parse returned nothing. Empties arrive from operations: an intersection of two shapes that do not touch, a buffer with a negative distance larger than the shape, a difference that removed everything. An empty is usually a correct answer to a question, which is exactly why it is easy to propagate without noticing.
The two middle columns disagree on four of the five rows. Treating them as one state is where the bugs come from.
Deterministic Configuration
Neither state needs special session configuration, but both need a decision recorded before the data is loaded, because the decision changes what the load produces.
INSTALL spatial; LOAD spatial;
SET memory_limit = '4GB';
SET threads = 8;
Optimized Execution Pattern
The pattern is to make both states explicit at ingest and never let them travel as a surprise. That means counting them, labelling them, and choosing a representation — rather than filtering them out, which loses the row, or ignoring them, which loses the count.
-- ANTI-PATTERN: the null rows are gone and nobody knows how many there were.
CREATE TABLE parcels AS
SELECT * FROM staging WHERE geom IS NOT NULL;
-- PATTERN: label the state, keep the row, and let downstream queries filter
-- on an explicit column rather than on the absence of a value.
CREATE OR REPLACE TABLE parcels AS
SELECT
parcel_id, zone_id, geom,
CASE
WHEN geom IS NULL THEN 'null'
WHEN ST_IsEmpty(geom) THEN 'empty'
WHEN NOT ST_IsValid(geom) THEN 'invalid'
ELSE 'ok'
END AS geom_state
FROM staging;
-- Every downstream query then says what it wants, out loud.
SELECT zone_id, count(*) AS n, sum(ST_Area(geom)) AS area
FROM parcels
WHERE geom_state = 'ok'
GROUP BY zone_id;
The gain is not that the filter is shorter — it is that geom_state = 'ok' is a statement about intent that survives code review, whereas geom IS NOT NULL AND NOT ST_IsEmpty(geom) AND ST_IsValid(geom) is a condition three people will each edit differently and one of them will drop a clause from.
Diagnostic Queries & Plan Validation
Nulls and empties are cheap to count and the count is the whole diagnostic. What matters is running it at the right moments: after ingest, and after any operation that can produce an empty.
-- The three-state census. Run it after ingest and after every overlay.
SELECT
count(*) AS rows_total,
count(*) FILTER (WHERE geom IS NULL) AS rows_null,
count(*) FILTER (WHERE ST_IsEmpty(geom)) AS rows_empty,
count(*) FILTER (WHERE geom IS NOT NULL AND NOT ST_IsEmpty(geom)) AS rows_present
FROM parcels;
Diagnostic — empties produced by an operation: an intersection that returns no rows and an intersection that returns rows containing empty geometry look identical in a row count and are completely different findings. The first means nothing overlapped; the second means something overlapped in a way that produced no area, which is usually a precision problem.
-- After an overlay: how many results are empty rather than absent?
-- A high proportion points at a precision or a snapping problem, not at
-- genuinely disjoint inputs.
SELECT
count(*) AS result_rows,
count(*) FILTER (WHERE ST_IsEmpty(geom)) AS empty_results,
count(*) FILTER (WHERE ST_IsEmpty(geom))::DOUBLE / nullif(count(*), 0) AS empty_share
FROM (SELECT ST_Intersection(a.geom, b.geom) AS geom
FROM zones a JOIN parcels b ON a.geom && b.geom);
Every row is a correct answer. Counting them after an overlay is more useful than trying to prevent them.
Geometry Validation & Fallback Routing
The guard that matters most is on aggregates, because that is where the two states diverge most visibly and least noisily. count(*) counts every row including nulls; count(geom) counts only rows where the geometry is present; sum(ST_Area(geom)) skips nulls and adds zero for empties. Three plausible counts of “how many features are in this zone” that disagree, all correct, all silent.
-- State the intent in the aggregate rather than relying on the default.
SELECT
zone_id,
count(*) AS records, -- every row
count(*) FILTER (WHERE geom_state = 'ok') AS with_geometry, -- usable rows
count(*) FILTER (WHERE geom_state = 'empty') AS zero_area, -- present, no space
sum(ST_Area(geom)) FILTER (WHERE geom_state = 'ok') AS total_area
FROM parcels
GROUP BY zone_id;
For a join, the guard is to decide whether a row with no usable geometry should survive. An inner join drops it either way; a left join keeps it with nulls on the right, which is usually what a record-keeping query wants and never what a spatial analysis wants. Choosing deliberately, per query, is the whole of the discipline.
Why the distinction survives into the output
It is tempting to treat the three states as an internal concern that can be flattened before anything leaves the pipeline. It cannot, because the consumer usually needs to distinguish them too. A map that draws nothing for a feature has to know whether the feature has no boundary recorded, or has a boundary that encloses no area, or was never in the extract — three different things that look identical once the state is discarded.
The practical form is to carry the state column through to the output and to name the three counts in whatever summary accompanies it. A row count on its own invites the reader to assume every row is drawable, and the moment two counts of “how many parcels are in this zone” disagree by a handful, the reconciliation costs more than carrying the column ever did.
-- The summary that travels with an extract. Three numbers rather than one,
-- because the consumer will otherwise derive their own and get it wrong.
SELECT
count(*) AS records_extracted,
count(*) FILTER (WHERE geom_state = 'ok') AS drawable,
count(*) FILTER (WHERE geom_state <> 'ok') AS not_drawable,
string_agg(DISTINCT geom_state, ', ') AS states_present
FROM parcels_export;
Frequently Asked Questions
Is an empty geometry invalid?
No — it is valid, and ST_IsValid returns true for it. That is precisely what makes it slip past a validity gate. An empty geometry is a well-formed value that describes no space, which is a legitimate result of an intersection between disjoint shapes and a legitimate thing to store.
What is the difference between NULL and EMPTY in practice?
A null propagates nullness: every predicate involving it returns null, which is not true, so the row fails every filter including a negated one. An empty propagates falseness: predicates return false, area returns zero, and the row behaves like a real feature that happens to match nothing. They need different guards, and code that checks only for null misses the other half.
Should I convert nulls to empty geometries?
Usually not. It removes a null check by removing the information the check existed for — “no boundary was ever recorded” and “this feature has no area” become the same value, and no later query can tell them apart. Keep them distinct and make the distinction explicit with a state column.
Why does my overlay produce empty geometries rather than no rows?
Because the inputs overlapped by an amount that rounds to nothing. That is usually a precision signal rather than a topology one: two shapes sharing an edge produce a zero-area intersection along it. Snapping both inputs to a common precision grid before the overlay eliminates most of these, and counting the empty share afterwards tells you whether the remainder is worth investigating.
Related
- Geometry validity and repair — the gate that lets both of these states through.
- Detecting invalid geometry at ingest — where the three-state census belongs.
- Vectorized aggregations — where the three counts of “how many” quietly disagree.