4.6. Geometry Validation and Quality Control#

4.6.1. Learning objectives#

By the end of this section, you should be able to:

  • run a repeatable inspection routine immediately after reading a layer;

  • distinguish invalid, empty, and missing geometries and report each separately;

  • diagnose and repair invalid geometry, and record what the repair changed;

  • turn the invariants of an operation into assertions;

  • explain the difference between set_crs and to_crs; and

  • record the metadata needed to interpret a result responsibly.

Section goal

Build the checks that make assumptions visible before they become maps, statistics, or decisions. A toy parks layer with one planted defect of each kind shows what every check finds.

A spatial operation can return a result without raising an error and still answer the wrong question. Quality control makes assumptions visible before they become maps, statistics, or decisions.

4.6.2. A repeatable inspection routine#

Run a small set of checks immediately after reading each layer. Inspection is cheap, and it finds the problems that every later stage inherits. Table 4.21 lists each check and what it reveals.

Table 4.21 The inspection checklist and what each check reveals.#

Check

What it reveals

How

Row count and identifiers

Duplicate or missing records

len, duplicated

CRS

A missing or wrong reference system

gdf.crs

Geometry types

Mixed or unexpected multipart types

geom_type.value_counts()

Missing, empty, invalid geometry

Rows that break later operations

isna, is_empty, is_valid

Bounds

Swapped or wrong-CRS coordinates

total_bounds

Attribute nulls, units, dates

Incomplete or stale records

isna().sum(), dtypes

A plotted sample

Problems no summary statistic shows

plot()

A clean layer would show nothing, so the toy parks layer below plants one defect of each kind: a self-crossing bow-tie, an empty polygon, a missing geometry, a duplicated identifier, and a missing name. Its coordinates are in metres in a UTM CRS.

Hide code cell source

import geopandas as gpd
import pandas as pd
from shapely.geometry import Polygon, box

parks = gpd.GeoDataFrame(
    {
        'asset_cd': ['P-001', 'P-002', 'P-003', 'P-004', 'P-005', 'P-002'],
        'site_name': ['Central Green', 'Riverside', 'Bow-tie Park', 'Empty Lot', None, 'Riverside East'],
    },
    geometry=[
        box(0, 0, 100, 100),                                    # fine
        box(200, 0, 300, 100),                                  # fine, but its id repeats below
        Polygon([(400, 0), (500, 100), (500, 0), (400, 100)]),  # bow-tie: invalid
        Polygon(),                                              # empty
        None,                                                   # missing
        box(600, 0, 700, 100),                                  # duplicate id P-002
    ],
    crs='EPSG:32611',
)
print(parks.drop(columns='geometry'))
  asset_cd       site_name
0    P-001   Central Green
1    P-002       Riverside
2    P-003    Bow-tie Park
3    P-004       Empty Lot
4    P-005             NaN
5    P-002  Riverside East

Hide code cell source

def inspect_geodataframe(gdf, id_column=None):
    # Avoid .notna() completely to bypass the warning trigger
    is_missing = gdf.geometry.isna()
    is_empty = gdf.geometry.is_empty
    has_geometry = ~is_missing & ~is_empty
    
    report = {
        "rows": len(gdf),
        "columns": list(gdf.columns),
        "crs": gdf.crs.to_string() if gdf.crs else None,
        "geometry_types": gdf.geom_type.fillna("missing").value_counts().to_dict(),
        "missing_geometry": int(is_missing.sum()),
        "empty_geometry": int(is_empty.sum()),
        "invalid_geometry": int((has_geometry & ~gdf.geometry.is_valid).sum()),
        "null_attributes": {
            column: int(count)
            for column, count in gdf.drop(columns=gdf.geometry.name).isna().sum().items()
            if count
        },
    }
    if id_column is not None and id_column in gdf:
        report["duplicate_ids"] = int(gdf[id_column].duplicated().sum())
    return report

print(inspect_geodataframe(parks, id_column="asset_cd"))
print("bounds:", parks.total_bounds.tolist())
{'rows': 6, 'columns': ['asset_cd', 'site_name', 'geometry'], 'crs': 'EPSG:32611', 'geometry_types': {'Polygon': 5, 'missing': 1}, 'missing_geometry': 1, 'empty_geometry': 1, 'invalid_geometry': 1, 'null_attributes': {'site_name': 1}, 'duplicate_ids': 1}
bounds: [0.0, 0.0, 700.0, 100.0]

The report finds each planted defect: one missing geometry, one empty geometry, one invalid geometry, one duplicated identifier, and one null name. It excludes missing geometries from the invalid count on purpose, because is_valid returns False for a missing geometry, so counting it would report an invalid geometry that does not exist. Run the report before and after every repair or filter, and keep the output with the analysis.

Also inspect total_bounds (which ignores missing and empty geometries), units, dates, and a plotted sample. Verify that the CRS is defined rather than assuming that a missing CRS means WGS 84.

4.6.3. Invalid, empty, and missing geometries#

Common invalidity causes include self-intersections, unclosed rings, duplicate vertices, and incorrect holes (S01 lists the rules [Herring, 2011]). Empty and missing geometries are different:

  • an empty geometry is present but has no coordinates;

  • a missing geometry is null; and

  • an invalid geometry has coordinates that violate the rules for its type.

Table 4.22 shows why the three must be counted separately: each one fails differently.

Table 4.22 How missing, empty, and invalid geometries behave.#

Case

is_valid

is_empty

Area

Effect in later operations

Missing (None)

False

False

NaN

Nulls propagate; must be counted and handled

Empty

True

True

0

Valid but matches nothing and adds no area

Invalid

False

False

Unreliable, and can be 0

Overlays fail or return wrong areas

Hide code cell source

summary = pd.DataFrame({
    'asset_cd': parks['asset_cd'],
    'missing': parks.geometry.isna(),
    'empty': parks.geometry.is_empty,
    'valid': parks.geometry.is_valid,
    'area_m2': parks.area,
})
print(summary.to_string(index=False))
asset_cd  missing  empty  valid  area_m2
   P-001    False  False   True  10000.0
   P-002    False  False   True  10000.0
   P-003    False  False  False      0.0
   P-004    False   True   True      0.0
   P-005     True  False  False      NaN
   P-002    False  False   True  10000.0

The missing row has a NaN area and the empty row has an area of 0, but the bow-tie also reports 0 even though it encloses two triangles. That is how invalid geometry produces a plausible but wrong number.

Repair only after understanding the source problem. make_valid [GEOS: Geometry Engine - Open Source, 2024, Gillies et al., 2026] rebuilds the geometry from the same coordinates, splitting it at each self-intersection, so a polygon can come back as a MultiPolygon or a GeometryCollection. With Shapely versions that provide make_valid, a repair can be recorded explicitly: keep the original layer, safely filter out both missing values and structurally empty geometries to avoid modern library warnings, repair only rows that have a geometry, and log what changed.

Hide code cell source

from shapely.validation import explain_validity, make_valid
import pandas as pd

parks_original = parks.copy() # retain the source layer

# FIX: Combines safe mask filters to avoid the UserWarning and the AttributeError
invalid = ~parks.geometry.isna() & ~parks.geometry.is_empty & (parks.geometry.is_valid == False)

for idx in parks.index[invalid]:
    print(parks.at[idx, 'asset_cd'], '->', explain_validity(parks.at[idx, 'geometry']))

parks.loc[invalid, "geometry"] = parks.loc[invalid, "geometry"].map(make_valid)

repairs = pd.DataFrame({
    'type before': parks_original.geom_type[invalid],
    'type after': parks.geom_type[invalid],
    'area before': parks_original.area[invalid],
    'area after': parks.area[invalid],
})

print()
print(repairs)
P-003 -> Self-intersection[450 50]

  type before    type after  area before  area after
2     Polygon  MultiPolygon          0.0      5000.0

The bow-tie becomes a two-part MultiPolygon with the area it should have always had. Selecting only rows with a geometry matters, because make_valid raises an error on a missing value.

The often-used buffer(0) workaround may repair some polygon defects, but it can change geometry type or remove small parts. The comparison below shows how much it can remove. Treat buffer(0) as a documented fallback, not a universal validator. Re-run the checks after repair and retain the original layer.

Hide code cell source

if invalid.any():
    bow_tie = parks_original.geometry[invalid].iloc[0]
    for name, geom in [('make_valid', make_valid(bow_tie)), ('buffer(0)', bow_tie.buffer(0))]:
        print(f'{name:10} {geom.geom_type:13} area={geom.area:.0f} m²')
else:
    print("Skipping comparison: No invalid geometries were found in the dataset to demonstrate repairs on.")

print()
print(inspect_geodataframe(parks, id_column="asset_cd"))
make_valid MultiPolygon  area=5000 m²
buffer(0)  Polygon       area=2500 m²

{'rows': 6, 'columns': ['asset_cd', 'site_name', 'geometry'], 'crs': 'EPSG:32611', 'geometry_types': {'Polygon': 4, 'MultiPolygon': 1, 'missing': 1}, 'missing_geometry': 1, 'empty_geometry': 1, 'invalid_geometry': 0, 'null_attributes': {'site_name': 1}, 'duplicate_ids': 1}

make_valid keeps both lobes (5,000 m²), whereas buffer(0) silently keeps only one (2,500 m²) and raises no error. After the repair the invalid count is zero, and everything else in the report is unchanged: repair fixes geometry only, so the missing geometry, the empty polygon, and the duplicated identifier still need a decision.

4.6.4. Invariants for common operations#

Use expectations that reflect the operation. Table 4.23 pairs each operation with the invariant it must satisfy and an assertion that tests it. Assertions turn those expectations into early failures:

Table 4.23 Invariants for common operations and the assertions that test them.#

Operation

Invariant

Assertion

within filter

Cannot create more rows than the input

assert len(out) <= len(inp)

Left attribute join, unique right key

Retains the left row count

assert len(out) == len(left)

difference(A, B)

Adds no area outside A

assert out.area.sum() <= A.area.sum() + tol

Overlay

Pieces satisfy the area identity from S04

assert math.isclose(lhs, rhs)

Metric calculation

Lengths and areas are non-negative

assert (out.area >= 0).all()

Any output

Identifiers trace back to the source

assert out['id'].notna().all()

Hide code cell source

import math

# Stand-ins for an operation's output and its expected CRS: the rows an area analysis can use.
expected_crs = parks.crs

# FIX: Swapped out .notna() to prevent the GeoPandas warning
result = parks[~parks.geometry.isna() & ~parks.geometry.is_empty]

assert result.crs == expected_crs
assert (~result.geometry.isna()).all()       # FIX: Warning-free missing value check
assert (result.geometry.area >= 0).all()
assert len(result) <= len(parks)            # a filter cannot add rows
print('all invariants hold:', len(result), 'of', len(parks), 'rows kept')
all invariants hold: 4 of 6 rows kept

The filter keeps four rows: P-001, both P-002 rows, and the repaired P-003. A passing geometry check says nothing about the duplicated identifier, which still appears twice among them, so the identifier needs its own decision.

An assertion earns its place when it fails. The unrepaired layer breaks the validity expectation, and the failure carries a message that says why. Use tolerances for floating-point comparisons [Goldberg, 1991] and explain exceptions instead of disabling checks.

Hide code cell source

import math

try:
    assert parks_original.geometry.dropna().is_valid.all(), 'the unrepaired layer contains invalid geometry'
except AssertionError as error:
    print('AssertionError:', error)

# Floating-point comparisons need a tolerance.
print(0.1 + 0.2 == 0.3, math.isclose(0.1 + 0.2, 0.3))
AssertionError: the unrepaired layer contains invalid geometry
False True

The assertion fails on exactly the layer known to be faulty, and its message names the cause. The second line shows why tolerances matter: 0.1 + 0.2 == 0.3 is False while math.isclose returns True, so equality tests on computed areas or distances should use a tolerance.

4.6.5. CRS and units#

Every operation involving two layers should begin with a CRS comparison, and every measurement needs its units. Table 4.24 separates the two methods that are most often confused.

Table 4.24 The difference between set_crs and to_crs.#

Method

What it does

Changes the coordinates?

Use when

set_crs

Labels coordinates that already use a known CRS

No

The CRS is missing and you know what it truly is

to_crs

Transforms coordinates between two known CRSs

Yes

Layers must share a CRS, or you need metres

set_crs [Jordahl et al., 2020] refuses to replace an existing CRS unless allow_override=True, which is a guard against relabelling by accident. The cell below compares the CRSs of a layer pair and aligns them, and reports the units that each CRS uses.

Hide code cell source

from shapely.geometry import Point

left = parks
right = gpd.GeoDataFrame(
    {'site': ['library']}, geometry=[Point(-114.0573, 51.0460)], crs='EPSG:4326'
)
print('right before:', right.crs.to_string(), '-', right.crs.axis_info[0].unit_name)

if left.crs != right.crs:
    right = right.to_crs(left.crs)

print('right after: ', right.crs.to_string(), '-', right.crs.axis_info[0].unit_name)
right before: EPSG:4326 - degree
right after:  EPSG:32611 - metre

Reprojection does not fix a wrongly assigned CRS. set_crs labels coordinates that already use a known CRS; to_crs transforms coordinates between known CRSs. Confusing the two can move data by hundreds or thousands of kilometers. The example below labels longitude and latitude values as if they were UTM metres.

Hide code cell source

from pyproj import Geod

library = gpd.GeoSeries([Point(-114.0573, 51.0460)], crs='EPSG:4326')
print('to_crs  :', library.to_crs('EPSG:32611').iloc[0])         # correct: the numbers change

unlabelled = gpd.GeoSeries([Point(-114.0573, 51.0460)])          # degrees, but no CRS recorded
mislabelled = unlabelled.set_crs('EPSG:32611')                   # wrong: the numbers do not change
print('set_crs :', mislabelled.iloc[0])

back = mislabelled.to_crs('EPSG:4326')
error_km = Geod(ellps='WGS84').inv(-114.0573, 51.0460, back.iloc[0].x, back.iloc[0].y)[2] / 1000
print(f'mislabelled point lands {error_km:,.0f} km from the true location')
to_crs  : POINT (706264.8946586304 5659061.020824244)
set_crs : POINT (-114.0573 51.046)
mislabelled point lands 5,701 km from the true location

to_crs changed the numbers, giving an easting of 706,265 m and a northing of 5,659,061 m, whereas set_crs left the degree values untouched and merely called them metres. The mislabelled point lands 5,701 km from Calgary, and nothing raises an error, because every number is still a legal coordinate.

4.6.6. Responsible interpretation#

Record the source, retrieval date, spatial extent, CRS, predicate or overlay, thresholds, and unit of analysis [Wilkinson et al., 2016]. Table 4.25 explains why each item matters.

Table 4.25 Metadata to record with every derived result.#

Item

Why it matters

Example

Source and retrieval date

Portals update, and results must be reproducible

Open-data portal name and download date

Spatial extent

Defines where absence is meaningful

City boundary or study area

CRS

Gives the coordinates their meaning

Stored in EPSG:4326, analysed in a UTM zone

Predicate or overlay

Determines how boundary contact is treated

intersects compared with within

Thresholds

Findings change with them

A 500 m service radius

Unit of analysis

States what one row counts

Community district

Absence of a feature usually means “not observed,” not “does not exist,” unless the source guarantees complete coverage. Check sensitivity to buffers, boundary predicates, and missing values before presenting a conclusion.

Network-specific connectivity checks are introduced separately in Chapter 5.

4.6.7. Summary#

Validation is part of the analysis, not a final cosmetic step. Inspect geometry, attributes, CRS, identifiers, and units; keep missing, empty, and invalid geometries separate; repair cautiously and log what each repair changed; encode invariants as assertions; and report limitations with every derived result.