Spatial Data Quality: The Six Dimensions That Matter

On this page

Problem statement

"Is this dataset any good?" is the question every GIS project starts with and almost none answers precisely. What usually happens instead:

print(len(gdf), "rows")
print(gdf.geometry.notna().sum(), "with a location")        # it opens, it draws β€” so it must be fine?
462 rows
338 with a location

This is the Food Standards Agency's register of food businesses in Rutland: real, official, updated weekly, and open. It opens and it draws. It also has a quarter of its rows without a location, rating values that are not ratings, ratings with no date, and dozens of businesses stacked on shared coordinates β€” none of which the two lines above would tell you.

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:

  1. Completeness β€” are all the features and attribute values there?
  2. Positional accuracy β€” are the coordinates where the real thing is?
  3. Attribute accuracy β€” are the values correct, and from a known domain?
  4. Logical consistency β€” is it internally coherent: valid geometry, no duplicates, referential integrity?
  5. Temporal validity β€” when was it true, and is it still?
  6. Lineage β€” where did it come from, and through what processing?
print("features        :", len(gdf))
print("null geometry   :", int(gdf.geometry.isna().sum()))
print("invalid geometry:", int((~gdf.geometry.dropna().is_valid).sum()))
print("duplicate ids   :", int(gdf["FHRSID"].duplicated().sum()))
print("rating values   :", gdf["RatingValue"].value_counts().to_dict())
print("declared CRS    :", gdf.crs.to_epsg())
print("extent          :", [round(v) for v in gdf.total_bounds])
print("attribute nulls :", gdf.drop(columns="geometry").isna().sum().sort_values(ascending=False).head(4).to_dict())
features        : 462
null geometry   : 124
invalid geometry: 0
duplicate ids   : 0
rating values   : {'5': 354, '4': 39, 'Exempt': 27, 'AwaitingInspection': 25, '3': 10, '2': 4, '1': 3}
declared CRS    : 27700
extent          : [481609, 293566, 504554, 317703]
attribute nulls : {'AddressLine1': 239, 'AddressLine4': 170, 'Geocode.Longitude': 124, 'Geocode.Latitude': 124}

A few 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

The six dimensions of spatial data quality and how each is tested. six independent questionsaskstested bycompletenessis anything missing?counts, nulls, sentinelspositional accuracyis it in the right place?compare with a referenceattribute accuracyare the values right?domains, ranges, geometrylogical consistencydoes it contradict itself?validity, overlaps, keystemporal validitywhen was it true?date columns, currencylineage!where did it come from?!recorded, not measuredA dataset can pass five of these and be unusable on the sixth spatialworkflow.io
Six independent questions β€” a dataset can pass any five and fail the sixth.

The six dimensions, measured one by one

Running a spatial data quality assessment, cheapest checks first. cheapest checks first1inventory: counts, CRS, extentheader reads2completeness: nulls + sentinels"UNKNOWN" counts3geometry: valid, empty, duplicateconsistency4attributes: domains and rangesvs the spec5cross-check against geometrystated vs computed6write the report beside the dataa trend, not an opinionStated area versus computed area is the check only GIS can make spatialworkflow.io
An assessment runs cheapest-first, and ends in a report you can hand to the supplier.

Completeness: is anything missing?

Completeness has two halves β€” missing features and missing values β€” and they are measured differently:

completeness = (1 - gdf.drop(columns="geometry").isna().mean()).sort_values()
print(completeness.head(5).round(3).to_dict())

# "unknown" sentinels count as missing, even though they are not null
SENTINELS = {"", "UNKNOWN", "N/A", "-", "AwaitingInspection", "Exempt"}
for col in ("RatingValue", "BusinessName", "PostCode"):
    hits = gdf[col].isin(SENTINELS).sum()
    if hits:
        print(f"{col}: {hits} sentinel values ({hits / len(gdf):.1%}) β€” {gdf.loc[gdf[col].isin(SENTINELS), col].value_counts().to_dict()}")
{'AddressLine1': 0.483, 'AddressLine4': 0.632, 'Geocode.Latitude': 0.732, 'Geocode.Longitude': 0.732, 'PostCode': 0.781}
RatingValue: 52 sentinel values (11.3%) β€” {'Exempt': 27, 'AwaitingInspection': 25}

RatingValue is never null, and one value in nine is not a rating. "AwaitingInspection" and "Exempt" are legitimate states, but they are the same thing as a missing value to anybody averaging ratings. Sentinel values are the trap: a column 100% populated scores perfectly on a null check and can be substantially empty in practice.

Feature completeness needs something to compare against. Here the check is internal β€” how many businesses the register can place on a map:

located = gdf.geometry.notna()
print(f"located: {located.sum()} of {len(gdf)} ({located.mean():.1%})")
print(gdf.loc[~located, "BusinessType"].value_counts().head(4).to_dict())
located: 338 of 462 (73.2%)
{'Other catering premises': 53, 'Mobile caterer': 18, 'Retailers - other': 17, 'Restaurant/Cafe/Canteen': 11}

The unlocated businesses are not random: they are dominated by "other catering premises" and mobile caterers β€” businesses without a fixed public address β€” which is exactly the kind of bias that turns a map of "food businesses" into a map of premises.

Positional accuracy: are the coordinates right?

Three questions β€” gross errors, agreement with a reference, and claimed precision:

points = gdf[located]

# 1. gross errors: is it in the right place at all?
inside = points.within(rutland)
print(f"inside Rutland: {inside.sum()} of {len(points)}; outside: {points.loc[~inside, 'BusinessName'].tolist()[:3]}")

# 2. relative accuracy: offset from the ONS postcode centroid of the stated postcode
compared = points.join(postcodes, on="PostCode", how="inner")
offset = compared.distance(gpd.GeoSeries(gpd.points_from_xy(compared["eastings"], compared["northings"]), crs=27700, index=compared.index))
print(f"{len(compared)} compared: median offset {offset.median():.1f} m, {(offset < 1).mean():.0%} within 1 m, max {offset.max():,.0f} m")

# 3. resolution: how many businesses share an identical coordinate?
stacked = points.geometry.to_wkb().duplicated(keep=False)
print(f"{stacked.sum()} businesses share their exact coordinates with another")
inside Rutland: 338 of 338; outside: []
337 compared: median offset 14.2 m, 3% within 1 m, max 8,838 m
124 businesses share their exact coordinates with another

Every point is inside the county, and every point is near its postcode: half within 14 m of the ONS centroid for the stated postcode, which is about the size of a postcode. The worst sits 8.8 km away β€” a wrong postcode or a wrong point, and the check can only say that the two disagree. A third of the businesses share their exact coordinates with another, which is what a geocoder does when it places several addresses at one point, and a statement of positional accuracy the coordinates themselves, with their many decimal places, do not make. Absolute accuracy needs ground truth; relative accuracy, agreement with another source, is what you can usually measure.

Attribute accuracy: are the values right?

# domain check: is every value in the allowed set?
ALLOWED = {"0", "1", "2", "3", "4", "5", "Exempt", "AwaitingInspection", "AwaitingPublication"}
print("values outside the domain:", sorted(set(gdf["RatingValue"].unique()) - ALLOWED))

# cross-check an attribute against the geometry
stated = gdf["LocalAuthorityName"].value_counts().to_dict()
print("LocalAuthorityName:", stated, "| located points outside Rutland:", int((~inside).sum()))
values outside the domain: []
LocalAuthorityName: {'Rutland': 462} | located points outside Rutland: 0

Every value is in the published domain. The geometry is an independent measurement of some attributes, so the two can be cross-examined β€” a check with no equivalent in tabular data: every business says it is in Rutland, and every located point agrees.

What each quality dimension found in the Food Standards Agency register of Rutland’s 462 food businesses. one register, six dimensionsdimensionmeasuredfindingcompleteness338 of 462 locatedmobile caterers missingcompleteness52 ratings not ratingsExempt, Awaiting…positionalmedian 14 m from postcode124 share a pointattributedomain and county agreepassesconsistencyids, geometry, duplicatespassestemporal2023–2026, 52 undatedundated = unratedTwo passes, four findings β€” the two lines that opened the page saw only the first spatialworkflow.io
The step-by-step checks, summarised.

Logical consistency: is it internally coherent?

geom = gdf.geometry.dropna()
print("invalid geometry  :", int((~geom.is_valid).sum()))
print("empty geometry    :", int(geom.is_empty.sum()))
print("geometry types    :", geom.geom_type.value_counts().to_dict())
print("duplicate FHRSID  :", int(gdf["FHRSID"].duplicated().sum()))
dupe_names = gdf.duplicated(["BusinessName", "PostCode"], keep=False)
print("same name and postcode:", int(dupe_names.sum()), gdf.loc[dupe_names, "BusinessName"].head(4).tolist())
invalid geometry  : 0
empty geometry    : 0
geometry types    : {'Point': 338}
duplicate FHRSID  : 0
same name and postcode: 0 []

Clean identifiers, valid points, no business registered twice under the same name and postcode. This register passes every consistency check here β€” which is itself worth recording, because "consistent" is a finding only once you have looked.

A layer that is meant to tile β€” parcels, administrative areas β€” has rules no attribute check would catch; example 3 runs them on Rutland's Land Registry parcels.

Temporal validity: when was this true?

rated = pd.to_datetime(gdf["RatingDate"], errors="coerce")
print("rating dates:", rated.min().date(), "β†’", rated.max().date(), "| undated:", int(rated.isna().sum()))
age_years = (pd.Timestamp("2026-09-30") - rated).dt.days / 365.25
print(f"median age {age_years.median():.1f} years; older than 5 years: {int((age_years > 5).sum())}")
rating dates: 2023-04-04 β†’ 2026-10-01 | undated: 52
median age 1.1 years; older than 5 years: 0

Every spatial dataset is a snapshot, and this one is many snapshots: each rating is as old as the last inspection, up to three and a half years here, and the 52 unrated businesses have no date at all. 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:

lineage = {
    "source": "Food Standards Agency, food hygiene rating data, Rutland (FHRS879)",
    "url": FSA_URL,
    "downloaded": "2026-09-30",
    "licence": "Open Government Licence v3.0",
    "source_crs": "EPSG:4326",
    "processing": ["flatten JSON with pandas.json_normalize", "points from Geocode.Longitude/Latitude",
                   "reproject to EPSG:27700"],
}
Path("data/clean").mkdir(parents=True, exist_ok=True)
Path("data/clean/fhrs_rutland.lineage.json").write_text(json.dumps(lineage, indent=2))
print(sorted(lineage))
['downloaded', 'licence', 'processing', 'source', 'source_crs', 'url']

Without lineage, every other dimension is unauditable: you can measure that the data disagrees with a reference, but not decide which one to believe.

Scoring Rutland's food register

The data used on this page

The Food Standards Agency's food hygiene rating data for Rutland (OGL), downloaded on 30 September 2026; postcode centroids for its postcodes from postcodes.io, which serves the ONS Postcode Directory (OGL); the Rutland boundary from the ONS December 2024 local authority boundaries (OGL); HM Land Registry's INSPIRE parcels for the county (Β© Crown copyright and database rights; OGL); and OpenStreetMap buildings from Geofabrik's Rutland extracts of 1 January 2025 and 1 January 2026 (Β© OpenStreetMap contributors, ODbL).

import io
import json
from pathlib import Path

import geopandas as gpd
import pandas as pd
import requests

# the FSA register file for Rutland as saved on 9 October 2026 (updated daily); live:
# ratings.food.gov.uk/api/open-data-files/FHRS879en-GB.json
FSA_URL = "https://data.spatialworkflow.io/fhrs-879-2026-10-09.json"
ONS = "https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
Path("data").mkdir(exist_ok=True)
downloads = {
    "data/food.json": FSA_URL,
    "data/Rutland.zip": "https://use-land-property-data.service.gov.uk/datasets/inspire/download/Rutland.zip",
    "data/rutland-250101.shp.zip": "https://download.geofabrik.de/europe/united-kingdom/england/rutland-250101-free.shp.zip",
    "data/rutland-260101.shp.zip": "https://download.geofabrik.de/europe/united-kingdom/england/rutland-260101-free.shp.zip",
}
for target, url in downloads.items():
    if not Path(target).exists():
        Path(target).write_bytes(requests.get(url, timeout=300).content)
if not Path("data/rutland.geojson").exists():
    Path("data/rutland.geojson").write_bytes(requests.get(ONS + "Local_Authority_Districts_December_2024_Boundaries_UK_BFC/FeatureServer/0/query",
        params={"where": "LAD24NM='Rutland'", "outFields": "LAD24NM", "outSR": 27700, "f": "geojson"}, timeout=120).content)

found = json.loads(Path("data/food.json").read_text())["FHRSEstablishment"]["EstablishmentCollection"]
records = pd.json_normalize(found if isinstance(found, list) else found["EstablishmentDetail"])
records = records.drop(columns=[c for c in ("Geocode", "Scores") if c in records])
x = pd.to_numeric(records["Geocode.Longitude"], errors="coerce")
y = pd.to_numeric(records["Geocode.Latitude"], errors="coerce")
geometry = gpd.GeoSeries(gpd.points_from_xy(x, y), crs=4326).where(x.notna())
gdf = gpd.GeoDataFrame(records, geometry=geometry, crs=4326).to_crs(27700)
rutland = gpd.read_file("data/rutland.geojson").geometry.iloc[0]

if not Path("data/postcodes.csv").exists():
    wanted = sorted(gdf["PostCode"].dropna().unique())
    rows = []
    for i in range(0, len(wanted), 100):
        for item in requests.post("https://api.postcodes.io/postcodes", json={"postcodes": wanted[i:i + 100]}, timeout=60).json()["result"]:
            if item["result"]:
                rows.append({"PostCode": item["query"], "eastings": item["result"]["eastings"], "northings": item["result"]["northings"]})
    pd.DataFrame(rows).to_csv("data/postcodes.csv", index=False)
postcodes = pd.read_csv("data/postcodes.csv").dropna().set_index("PostCode")
print(len(gdf), "businesses |", len(postcodes), "postcode centroids")
462 businesses | 197 postcode centroids

Example 1: a scored quality assessment

def assess(gdf, spec):
    """Score a layer against an explicit specification. Returns one row per check."""
    checks = []
    def add(dimension, check, value, threshold, ok):
        checks.append({"dimension": dimension, "check": check, "value": value, "threshold": threshold, "pass": bool(ok)})

    add("completeness", "feature count", len(gdf), spec["min_features"], len(gdf) >= spec["min_features"])
    located_pct = round(gdf.geometry.notna().mean() * 100, 1)
    add("completeness", "located %", located_pct, spec["min_located_pct"], located_pct >= spec["min_located_pct"])
    invalid = int((~gdf.geometry.dropna().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)
    add("positional", "declared CRS", gdf.crs.to_string(), spec["crs"], gdf.crs.to_string() == spec["crs"])
    outside = int((~gdf.geometry.dropna().within(spec["area"])).sum())
    add("positional", "outside the study area", outside, spec["max_outside"], outside <= spec["max_outside"])
    unexpected = sorted(set(gdf[spec["class_col"]]) - set(spec["allowed"]))
    add("attribute", "class domain", unexpected, [], not unexpected)
    old = int(((pd.Timestamp("2026-09-30") - pd.to_datetime(gdf[spec["date_col"]], errors="coerce")).dt.days > 5 * 365).sum())
    add("temporal", "older than 5 years", old, spec["max_old"], old <= spec["max_old"])
    return pd.DataFrame(checks)

SPEC = {"id_col": "FHRSID", "class_col": "RatingValue", "date_col": "RatingDate", "crs": "EPSG:27700",
        "min_features": 400, "min_located_pct": 90, "area": rutland, "max_outside": 0, "max_old": 10,
        "allowed": ["0", "1", "2", "3", "4", "5", "Exempt", "AwaitingInspection", "AwaitingPublication"]}
results = assess(gdf, SPEC)
print(results.to_string(index=False))
print(f"\n{results['pass'].sum()}/{len(results)} checks passed")
   dimension                  check      value  threshold  pass
completeness          feature count        462        400  True
completeness              located %       73.2         90 False
 consistency       invalid geometry          0          0  True
 consistency          duplicate ids          0          0  True
  positional           declared CRS EPSG:27700 EPSG:27700  True
  positional outside the study area          0          0  True
   attribute           class domain         []         []  True
    temporal     older than 5 years          0         10  True

7/8 checks passed

The thresholds are the specification: a 90% located rate is a choice, and writing it down is what makes the failure a finding rather than an opinion.

Example 2: compare a delivery against the last one

def delivery_report(path):
    buildings = gpd.read_file(f"/vsizip/{path}/gis_osm_buildings_a_free_1.shp")
    return {"buildings": len(buildings), "invalid": int((~buildings.is_valid).sum()),
            "named %": round(buildings["name"].notna().mean() * 100, 1),
            "typed %": round(buildings["type"].notna().mean() * 100, 1)}

previous = delivery_report("data/rutland-250101.shp.zip")
current = delivery_report("data/rutland-260101.shp.zip")
print(pd.DataFrame({"2025-01-01": previous, "2026-01-01": current}).T.to_string())
            buildings  invalid  named %  typed %
2025-01-01     5106.0      0.0      8.8     36.4
2026-01-01     5513.0      0.0      8.3     35.0

Two real deliveries of the same layer a year apart. A dimension that has been steady and suddenly moves is worth more attention than one that has always been mediocre β€” and without the earlier report, there is nothing to move against.

Example 3: a coverage check for parcel-style layers

def coverage_problems(layer, tolerance_m=0.05):
    pairs = gpd.sjoin(layer, layer, predicate="overlaps")
    pairs = pairs[pairs.index < pairs["index_right"]]
    overlap_area = sum(layer.geometry.loc[a].intersection(layer.geometry.loc[b]).area
                       for a, b in zip(pairs.index, pairs["index_right"]))
    merged = layer.union_all()
    closed = merged.buffer(tolerance_m).buffer(-tolerance_m)
    gaps = closed.difference(merged)
    return {"parcels": len(layer), "overlapping_pairs": len(pairs), "overlap_area_m2": round(overlap_area),
            "hairline_gap_area_m2": round(gaps.area, 1)}

parcels = gpd.read_file("/vsizip/data/Rutland.zip/Land_Registry_Cadastral_Parcels.gml", columns=["INSPIREID"], DOWNLOAD_SCHEMA="NO")
oakham = parcels[parcels.intersects(gdf.loc[gdf["PostCode"].str.startswith("LE15 6", na=False)].union_all().convex_hull)]
print(coverage_problems(oakham.reset_index(drop=True)))
{'parcels': 2253, 'overlapping_pairs': 1229, 'overlap_area_m2': 1023052, 'hairline_gap_area_m2': 12.7}

Over a square kilometre of overlap, and almost no hairline gaps. Land Registry parcels are title extents, and titles legitimately overlap β€” a lease within a freehold, a flying freehold β€” so overlaps are expected here and a finding elsewhere. Know the data model before flagging.

Example 4: publish the report with the data

from datetime import datetime, timezone

def write_quality_sidecar(gdf, results, dest, lineage):
    payload = {"assessed": "2026-09-30", "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, default=str), encoding="utf-8")
    return dest

path = write_quality_sidecar(gdf, results, Path("data/clean/fhrs_rutland.quality.json"), lineage)
print(path.name, path.stat().st_size, "bytes;", json.loads(path.read_text())["failed"], "checks failed")
fhrs_rutland.quality.json 1685 bytes; 1 checks failed

A quality sidecar travelling with the data is what turns "we checked it" into something a recipient can verify.

Why the dimensions are measured separately

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.

A complete but inaccurate dataset compared with an accurate but partial one. no single score distinguishes thesecomplete, inaccurateβœ•full coverage, no gapsβœ•gaps filled with estimatesβœ•suspiciously round numbersβœ•no nulls anywhere at allaccurate, partialβœ“only surveyed areas presentβœ“every value verifiableβœ“honest nulls where unknownβœ“60% coverage, documentedWhich is better depends entirely on what you are going to do with it spatialworkflow.io
Two datasets, opposite failures β€” no single score would distinguish them.

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. The FSA register chose the second: it leaves a business unlocated rather than guess, and geocodes the rest to a postcode rather than claim a door.

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.

Where quality checks mislead

  • A null check is not a completeness check: Sentinels like UNKNOWN, -9999 or 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.

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.