Spatial Data Quality: The Six Dimensions That Matter
Problem statement
"Is this dataset any good?" is the question every GIS project starts with and almost none answers precisely. What usually happens instead:
>>> gdf = gpd.read_file("data/raw/parcels.gpkg")
>>> len(gdf)
4812
>>> gdf.plot() # looks fine
It opens, it draws, so it must be fine. Three weeks later the analysis is wrong because 6% of the parcels are duplicated, the boundary layer is from 2019, a third of the class values are "UNKNOWN", and the CRS metadata says EPSG:4326 while the coordinates are British National Grid.
Data quality is not one thing you can measure with one number. It has dimensions β independent properties that can each be good or bad on their own β and knowing which six matter turns a vague worry into a checklist you can run.
Quick answer
Assess a spatial dataset along six dimensions, in this order:
- Completeness β are all the features and attribute values there?
- Positional accuracy β are the coordinates where the real thing is?
- Attribute accuracy β are the values correct, and from a known domain?
- Logical consistency β is it internally coherent: valid geometry, no overlaps, referential integrity?
- Temporal validity β when was it true, and is it still?
- Lineage β where did it come from, and through what processing?
import geopandas as gpd
gdf = gpd.read_file("data/raw/parcels.gpkg")
print("features :", len(gdf))
print("null geometry :", gdf.geometry.isna().sum())
print("invalid geometry:", (~gdf.geometry.is_valid).sum())
print("duplicate ids :", gdf["parcel_id"].duplicated().sum())
print("missing class :", gdf["class"].isna().sum())
print("declared CRS :", gdf.crs)
print("extent :", [round(v, 2) for v in gdf.total_bounds])
print("attribute nulls :\n", gdf.isna().sum().sort_values(ascending=False).head())
Eight lines that answer parts of four dimensions. The point of the framework is that they are different questions: a dataset can be complete and inaccurate, or accurate and inconsistent, and each failure has a different remedy.
The six dimensions
Step-by-step solution
Completeness: is anything missing?
Completeness has two halves β missing features and missing values β and they are measured differently.
import geopandas as gpd
# feature completeness: compare against an authority or an expectation
expected_parcels = 4_950 # from the council's published count
print(f"coverage: {len(gdf) / expected_parcels:.1%}")
# attribute completeness, per column
completeness = (1 - gdf.isna().mean()).sort_values()
print(completeness.head(8).to_string())
# "unknown" sentinels count as missing, even though they are not null
SENTINELS = {"", "UNKNOWN", "N/A", "-", "9999", "0"}
for col in gdf.select_dtypes("object").columns:
hits = gdf[col].isin(SENTINELS).sum()
if hits:
print(f"{col}: {hits} sentinel values ({hits/len(gdf):.1%})")
Sentinel values are the trap: a column that is 100% populated with "UNKNOWN" in a third of its rows scores perfectly on a null check and is a third empty in practice.
Positional accuracy: are the coordinates right?
import geopandas as gpd
# 1. gross errors β is it even in the right part of the world?
minx, miny, maxx, maxy = gdf.total_bounds
print("extent:", [round(v, 1) for v in gdf.total_bounds])
print("plausible for EPSG:27700:", 0 < minx < 700_000 and 0 < miny < 1_300_000)
# 2. relative accuracy β compare against a reference of known quality
reference = gpd.read_file("data/ref/os_parcels.gpkg").to_crs(gdf.crs)
joined = gdf.sjoin_nearest(reference[["ref_id", "geometry"]], distance_col="offset_m")
print(joined["offset_m"].describe()[["mean", "50%", "max"]])
# 3. resolution β how precise do the coordinates even claim to be?
decimals = gdf.geometry.get_coordinates()["x"].astype(str).str.split(".").str[1].str.len()
print("coordinate decimal places:", decimals.value_counts().head(3).to_dict())
Positional accuracy is meaningless without a reference. Absolute accuracy needs ground truth; relative accuracy β how well this layer agrees with another β is usually what you can actually measure, and it is often what matters.
Attribute accuracy: are the values right?
# domain check: is every value in the allowed set?
ALLOWED = {"residential", "commercial", "industrial", "agricultural", "other"}
unexpected = set(gdf["class"].dropna().unique()) - ALLOWED
print("values outside the domain:", sorted(unexpected)[:10])
# range check: are numbers physically possible?
suspect = gdf[(gdf["area_m2"] <= 0) | (gdf["area_m2"] > 5_000_000)]
print(f"{len(suspect)} implausible areas")
# internal agreement: does the attribute match the geometry?
computed = gdf.to_crs(gdf.estimate_utm_crs()).area
disagreement = ((computed - gdf["area_m2"]).abs() / computed)
print(f"{(disagreement > 0.05).sum()} rows where stated area differs from geometry by >5%")
That last check is the most valuable one available to a GIS practitioner and has no equivalent in tabular data: the geometry is an independent measurement of an attribute, so the two can be cross-examined.
Logical consistency: is it internally coherent?
import geopandas as gpd
geom = gdf.geometry
print("invalid geometry :", (~geom.is_valid).sum())
print("empty geometry :", geom.is_empty.sum())
print("duplicate geometry:", geom.geom_equals(geom.shift()).sum())
print("mixed types :", geom.geom_type.value_counts().to_dict())
# a parcel layer should be a coverage: no overlaps, no unintended gaps
overlaps = gpd.sjoin(gdf, gdf, predicate="overlaps")
overlaps = overlaps[overlaps.index != overlaps["index_right"]]
print(f"{len(overlaps) // 2} overlapping pairs")
# referential integrity between layers
missing_owner = ~gdf["owner_id"].isin(owners["owner_id"])
print(f"{missing_owner.sum()} parcels reference an owner that does not exist")
Consistency is where GIS differs most from ordinary data quality: geometry brings its own integrity rules, and a layer that is meant to be a coverage has properties (no overlaps, no gaps) that no attribute check would catch.
Temporal validity: when was this true?
import pandas as pd
print("survey dates:", gdf["surveyed_at"].min(), "β", gdf["surveyed_at"].max())
age_days = (pd.Timestamp.today() - pd.to_datetime(gdf["surveyed_at"])).dt.days
print("median age:", int(age_days.median()), "days")
print("older than 5 years:", (age_days > 5 * 365).sum())
# a dataset with no dates at all is a finding, not an absence
if "surveyed_at" not in gdf.columns:
print("! no temporal attribute β currency cannot be assessed from the data")
Every spatial dataset is a snapshot. Buildings are demolished, boundaries are reorganised, land use changes. A layer with no date column is not timeless; it is undated, which is worse.
Lineage: where did it come from?
Lineage is the only dimension you cannot compute β it must be recorded.
import json
from pathlib import Path
lineage = {
"source": "City Council open data portal",
"url": "https://example.gov/data/parcels",
"downloaded_utc": "2026-08-11T09:14:00Z",
"licence": "OGL v3.0",
"source_crs": "EPSG:27700",
"processing": ["make_valid", "reproject EPSG:27700", "drop empty geometry"],
"produced_by": {"script": "clean_parcels.py", "git_rev": "8c62b62"},
}
Path("data/clean/parcels.lineage.json").write_text(json.dumps(lineage, indent=2))
Without lineage, every other dimension is unauditable: you can measure that the data disagrees with a reference, but not decide which one to believe.
Turn it into a repeatable report
def quality_report(gdf, id_col="parcel_id", class_col="class", allowed=None) -> dict:
metric = gdf.to_crs(gdf.estimate_utm_crs())
report = {
"completeness": {
"features": len(gdf),
"null_geometry": int(gdf.geometry.isna().sum()),
"attribute_null_pct": (gdf.isna().mean() * 100).round(2).to_dict(),
},
"consistency": {
"invalid_geometry": int((~gdf.geometry.is_valid).sum()),
"duplicate_ids": int(gdf[id_col].duplicated().sum()),
"geometry_types": gdf.geom_type.value_counts().to_dict(),
},
"positional": {
"crs": str(gdf.crs),
"bounds": [round(float(v), 2) for v in gdf.total_bounds],
"min_area_m2": round(float(metric.area.min()), 2),
},
"attribute": {
"unexpected_classes": sorted(set(gdf[class_col].dropna()) - set(allowed or []))[:10],
},
}
return report
Run it on every delivery, store the output next to the data, and quality becomes a trend rather than an opinion.
Code examples
Example 1: a scored quality assessment
import geopandas as gpd
import pandas as pd
def assess(gdf: gpd.GeoDataFrame, spec: dict) -> pd.DataFrame:
"""Score a layer against an explicit specification. Returns one row per check."""
metric = gdf.to_crs(gdf.estimate_utm_crs())
checks = []
def add(dimension, check, value, threshold, ok):
checks.append({"dimension": dimension, "check": check,
"value": value, "threshold": threshold, "pass": ok})
n = len(gdf)
add("completeness", "feature count", n, spec.get("min_features", 0),
n >= spec.get("min_features", 0))
for col, limit in spec.get("max_null_pct", {}).items():
pct = round(gdf[col].isna().mean() * 100, 2)
add("completeness", f"{col} nulls %", pct, limit, pct <= limit)
invalid = int((~gdf.geometry.is_valid).sum())
add("consistency", "invalid geometry", invalid, 0, invalid == 0)
dupes = int(gdf[spec["id_col"]].duplicated().sum())
add("consistency", "duplicate ids", dupes, 0, dupes == 0)
crs_ok = gdf.crs and gdf.crs.to_string() == spec["crs"]
add("positional", "declared CRS", str(gdf.crs), spec["crs"], bool(crs_ok))
within = all(
lo <= v <= hi for v, (lo, hi) in zip(gdf.total_bounds, spec["bounds"])
) if spec.get("bounds") else True
add("positional", "extent in study area", [round(float(v)) for v in gdf.total_bounds],
spec.get("bounds"), within)
tiny = int((metric.area < spec.get("min_area_m2", 0)).sum())
add("attribute", "slivers below min area", tiny, 0, tiny == 0)
if "allowed_classes" in spec:
unexpected = sorted(set(gdf[spec["class_col"]].dropna()) - set(spec["allowed_classes"]))
add("attribute", "class domain", unexpected, [], not unexpected)
return pd.DataFrame(checks)
SPEC = {
"id_col": "parcel_id", "class_col": "class", "crs": "EPSG:27700",
"min_features": 4000, "min_area_m2": 5,
"max_null_pct": {"class": 2.0, "owner_id": 5.0},
"bounds": [(0, 700_000), (0, 1_300_000), (0, 700_000), (0, 1_300_000)],
"allowed_classes": ["residential", "commercial", "industrial", "agricultural", "other"],
}
results = assess(gpd.read_file("data/raw/parcels.gpkg"), SPEC)
print(results.to_string(index=False))
print(f"\n{results['pass'].sum()}/{len(results)} checks passed")
Example 2: compare a delivery against the last one
import pandas as pd
previous = pd.read_json("reports/quality_2026-07.json")
current = pd.read_json("reports/quality_2026-08.json")
merged = previous.merge(current, on=["dimension", "check"], suffixes=("_prev", "_now"))
changed = merged[merged["value_prev"].astype(str) != merged["value_now"].astype(str)]
print(changed[["dimension", "check", "value_prev", "value_now"]].to_string(index=False))
A dimension that has been fine for months and suddenly moves is worth more attention than one that has always been mediocre.
Example 3: a coverage check for parcel-style layers
import geopandas as gpd
def coverage_problems(gdf: gpd.GeoDataFrame, tolerance_m: float = 0.05) -> dict:
metric = gdf.to_crs(gdf.estimate_utm_crs())
pairs = gpd.sjoin(metric, metric, predicate="overlaps")
pairs = pairs[pairs.index < pairs["index_right"]]
overlap_area = sum(
metric.geometry.iloc[a].intersection(metric.geometry.iloc[b]).area
for a, b in zip(pairs.index, pairs["index_right"])
)
dissolved = metric.union_all()
gaps = dissolved.buffer(tolerance_m).buffer(-tolerance_m).difference(dissolved)
return {
"overlapping_pairs": len(pairs),
"overlap_area_m2": round(overlap_area, 2),
"gap_area_m2": round(gaps.area, 2),
"gap_pieces": len(getattr(gaps, "geoms", [])),
}
print(coverage_problems(gpd.read_file("data/raw/parcels.gpkg")))
Example 4: publish the report with the data
import json
from datetime import datetime, timezone
from pathlib import Path
def write_quality_sidecar(gdf, results, dest: Path, lineage: dict) -> Path:
payload = {
"assessed_utc": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"features": len(gdf),
"checks": results.to_dict(orient="records"),
"passed": int(results["pass"].sum()),
"failed": int((~results["pass"]).sum()),
"lineage": lineage,
}
dest.write_text(json.dumps(payload, indent=2), encoding="utf-8")
return dest
write_quality_sidecar(gdf, results, Path("data/clean/parcels.quality.json"), lineage)
A quality sidecar travelling with the data is what turns "we checked it" into something a recipient can verify.
Explanation
The six-dimension framing comes from the spatial-data-quality literature and the ISO 19157 standard, and its value is that the dimensions are independent. That independence is why a single "is it good?" question never works: a dataset can be complete and wrong, accurate and stale, internally consistent and mislabelled.
Completeness and accuracy are the two people think of first, and they pull in different directions. A supplier under pressure to deliver full coverage will fill gaps with estimates; a supplier optimising for accuracy will omit what they cannot verify. Knowing which one you are looking at changes how you use the data, and the tell is usually in the attributes: a suspiciously round number, a uniform value across a region, a column with no nulls at all.
Logical consistency is where spatial data has rules that tabular data does not. Geometry must be valid; a parcel layer should tile without overlaps or gaps; a road network should be connected; a point layer of addresses should fall inside its own boundary layer. These are checkable properties of the dataset as a whole, not of individual records, which is why they are so often missed by generic data-quality tooling.
Temporal validity is the dimension that degrades on its own. Nothing about a file changes as it ages, but its relationship to the world does β and the failure is silent, because the data still opens and still draws. This is why a dataset without a date column is a quality finding in itself, and why currency belongs in the assessment rather than in someone's memory.
Lineage is the meta-dimension: the only one that cannot be measured from the data, and the one that makes the other five actionable. When two layers disagree, lineage tells you which is derived from which, what processing has been applied, and who to ask. Recording it costs a JSON file per run; not recording it costs an afternoon every time a number is questioned.
Edge cases or notes
- A null check is not a completeness check: Sentinels like
UNKNOWN,-9999or an empty string are missing values that pass every null test. - Absolute accuracy needs ground truth: Without a reference of higher quality, you can only measure agreement between datasets, not correctness.
- Precision is not accuracy: Fifteen decimal places of longitude says nothing about whether the point is in the right field.
- Coverage checks are expensive: Pairwise overlap testing on a large layer needs a spatial index β use
sjoin, not a nested loop. - Some overlaps are legitimate: Leases, easements and multi-storey ownership legitimately overlap. Know the data model before flagging.
- Quality is fitness for purpose: A layer that is unusable for legal boundaries may be perfectly good for a heat map. Assess against a stated use.
- Scores hide detail: A single quality percentage feels satisfying and tells you nothing about which dimension failed. Keep the per-check table.
Internal links
- The Python GIS Data Cleaning Checklist: From Raw Download to Analysis-Ready
- How to Validate a GeoDataFrame Against a Schema Before Analysis
- How to Build a Repeatable Data-Cleaning Report in GeoPandas
- How to Find and Remove Spatial Outliers in a Point Dataset
- What Makes a Geometry Valid? The OGC Rules Explained
- How to Record Run Metadata and Data Lineage in a GIS Pipeline
FAQ
What are the dimensions of spatial data quality?
Completeness, positional accuracy, attribute accuracy, logical consistency, temporal validity and lineage. They are independent, which is why a dataset can score well on most and still be unusable.
How do I measure positional accuracy without ground truth?
You measure agreement with a reference layer of known quality instead, reporting the distribution of offsets. That is relative accuracy, and it is usually what is available and what matters.
Is an invalid geometry a completeness or a consistency problem?
Consistency. Completeness is about what is missing; consistency is about whether what is present contradicts itself.
What is the most under-checked dimension?
Temporal validity, closely followed by lineage. Neither shows up when the file opens, and both quietly determine whether the analysis means anything.
How do I stop "UNKNOWN" values passing a completeness check?
Test against an explicit sentinel set as well as nulls. Sentinels are a supplier's way of filling a required field, and they are missing data by any practical definition.
Should I reject a delivery that fails checks?
Report first. A quality table sent back to the supplier is more productive than a rejection, and it establishes which dimension failed β which is usually something they can fix at source.
How often should quality be assessed?
Every delivery, automatically, with the report stored beside the data. The value compounds: a dimension that suddenly changes between deliveries is a much stronger signal than any single assessment.