Metadata extents and dates do not match the data

On this page

Problem statement

The record says the dataset covers England and the data stops at Birmingham. It says 2026 and the newest feature is from 2023. The bounding box is in metres and the record claims EPSG:4326, so a catalogue search over the UK finds nothing: numbers like 485,000 are not longitudes, and a client either rejects the item or files it nowhere useful.

These are the errors no validator catches. A STAC item with a reversed bounding box validates cleanly; an ISO record with an extent copied from a previous version validates cleanly; a content date typed by hand validates cleanly. Schemas check shape, and all of these are correctly shaped and wrong.

The fix is to derive extents and dates from the data at write time, and to assert the derived values against the claims that are still authored.

Quick answer

import geopandas as gpd

def check_record(record, data_path, tolerance_deg=0.01):
    gdf = gpd.read_file(data_path)
    problems = []

    w, s, e, n = record["bbox"]
    if w > e or s > n:
        problems.append(f"bbox is not west, south, east, north: {record['bbox']}")
    if max(abs(v) for v in (w, e)) > 180 or max(abs(v) for v in (s, n)) > 90:
        problems.append("bbox is outside geographic range — is it projected?")

    actual = gdf.to_crs(4326).total_bounds
    if any(abs(a - b) > tolerance_deg for a, b in zip(actual, record["bbox"])):
        problems.append(f"bbox {record['bbox']} does not match the data {[round(float(v), 4) for v in actual]}")

    if record.get("crs") and gdf.crs and record["crs"] != gdf.crs.to_string():
        problems.append(f"CRS {record['crs']} does not match the data {gdf.crs.to_string()}")

    return problems


for name, record in records.items():
    found = check_record(record, "data/rutland_parcels.gpkg")
    print(f"{name:14} {'ok' if not found else found[0][:95]}")
derived        ok
reversed       bbox is not west, south, east, north: [-0.419778, 52.522344, -0.835034, 52.762346]
projected      bbox is outside geographic range — is it projected?
stale extent   bbox [-6.42, 49.86, 1.77, 55.81] does not match the data [-0.835, 52.5223, -0.4198, 52.7623]
wrong CRS      CRS EPSG:4326 does not match the data EPSG:27700
old date       ok

The records are for HM Land Registry's INSPIRE parcels for Rutland; the correct one is derived from the file, and each of the others carries one mistake of the kind this page is about, stated in its name (set up under Code examples). The stale extent is the most instructive: Rutland's record with England's box is geographic, correctly ordered, and passes every check except the one that compares it with the data.

Run it on the written file, not the frame in memory. Half of these errors are introduced by the write.

Five extent and date errors and the check that catches each. what no validator catcheserrorcheckbbox west is east of eastassert w <= e and s <= nbbox in projected coordinatesassert it is within ±180, ±90extent copied from the lastversionrecompute and comparecontent date is the publicationdatecompare against a date columnraster footprint from four cornersdensify before transformingAll five are correctly shaped documents describing the wrong data spatialworkflow.io
Five errors, five assertions; none of them is a schema check.

Bbox order, geographic bounds, recomputed extents

1. Confirm the bbox order

[west, south, east, north] for STAC and GeoJSON; ISO uses named elements. A reversed box is the single most common error and the one validators never catch, because [10, 50, 5, 55] is four valid numbers.

2. Confirm the bbox is geographic

STAC and GeoJSON bounding boxes are in EPSG:4326. A projected extent — values in the hundreds of thousands — is a strong sign that somebody wrote gdf.total_bounds without reprojecting.

3. Recompute the extent and compare

The extent in the record should be the data's extent. Tolerances matter: an extent rounded to four decimal places will not equal the data's exactly, so compare with a tolerance and state what it is.

4. Check that the extent is not stale

An extent copied from a previous version is subtly wrong in a way a tolerance check catches immediately. This is the value of deriving rather than authoring.

5. Separate the three dates

The content date (when the data describes the world), the publication date (when it was released) and the processing date (when the file was made) are three different facts. Most wrong dates are one of these standing in for another.

6. Check the dates against the data

If the dataset has a date column, the content date should be consistent with it:

if "VALIDFROM" in gdf:
    latest = gdf["VALIDFROM"].max()
    if str(record["content_date"]) < str(latest.date()):
        problems.append(f"content_date {record['content_date']} predates the newest "
                        f"feature {latest.date()}")

7. Check the time zone

A STAC datetime without a UTC designator fails validation; a content date recorded as a local timestamp near midnight silently shifts by a day. Store dates as ISO dates and datetimes as UTC.

8. Make the checks a gate

These are assertions, not warnings. A record that does not describe its data is worse than no record, because it is believed.

The three dates a spatial dataset has and what each answers. three dates, three questionscontent date2025-11-01when was this true?publication date2026-01-20when was it released?processing date2026-01-19when was the file made?review by2027-11-01when does it go stale?A 2024 survey published in 2026 is 2024 data spatialworkflow.io
Three dates, three questions; publishing one of them as all three is the usual error.

Deriving extents correctly, including rasters

The data used on this page

HM Land Registry's INSPIRE Index Polygons for Rutland (21,253 parcels, British National Grid, with the date each parcel's record became valid), six metadata records for it — one derived from the data and five each carrying one mistake — and a real STAC item: Earth Search's Sentinel-2 L2A scene S2A_30UVD_20250922_0_L2A over Snowdonia.

import io
import zipfile
from pathlib import Path

import geopandas as gpd
import pyogrio
import requests

pyogrio.set_gdal_config_options({"GML_DOWNLOAD_SCHEMA": "NO"})
Path("data").mkdir(exist_ok=True)
if not Path("data/rutland_parcels.gpkg").exists():
    reply = requests.Session().get("https://use-land-property-data.service.gov.uk/datasets/inspire/download/"
                                   "Rutland.zip", timeout=600)
    reply.raise_for_status()
    zipfile.ZipFile(io.BytesIO(reply.content)).extract("Land_Registry_Cadastral_Parcels.gml", "downloads")
    gpd.read_file("downloads/Land_Registry_Cadastral_Parcels.gml").to_file("data/rutland_parcels.gpkg")

parcels = gpd.read_file("data/rutland_parcels.gpkg")
bbox = [round(float(v), 6) for v in parcels.to_crs(4326).total_bounds]
newest = parcels["VALIDFROM"].max().date().isoformat()
good = {"bbox": bbox, "crs": "EPSG:27700", "content_date": newest, "published_date": "2026-09-01"}
records = {
    "derived":      good,
    "reversed":     {**good, "bbox": [bbox[2], bbox[1], bbox[0], bbox[3]]},
    "projected":    {**good, "bbox": [round(float(v)) for v in parcels.total_bounds]},
    "stale extent": {**good, "bbox": [-6.42, 49.86, 1.77, 55.81]},        # England's box, copied forward
    "wrong CRS":    {**good, "crs": "EPSG:4326"},
    "old date":     {**good, "content_date": "2021-03-31"},
}

item = requests.get("https://earth-search.aws.element84.com/v1/collections/sentinel-2-l2a/items/"
                    "S2A_30UVD_20250922_0_L2A", timeout=60).json()
print(f"{len(parcels):,} parcels, {parcels.crs}; bbox {bbox}; newest VALIDFROM {newest}")
print("STAC item bbox:", item["bbox"])
21,253 parcels, EPSG:27700; bbox [-0.835034, 52.522344, -0.419778, 52.762346]; newest VALIDFROM 2023-03-01
STAC item bbox: [-4.499044, 52.253459, -2.853738, 53.249528]

Example 1 — derive the extent correctly, including for rasters

import geopandas as gpd, rasterio
from rasterio.warp import transform_bounds

def geographic_bbox(path, round_to=6):
    if str(path).endswith((".tif", ".tiff")):
        with rasterio.open(path) as src:
            b = transform_bounds(src.crs, "EPSG:4326", *src.bounds, densify_pts=21)
    else:
        b = gpd.read_file(path).to_crs(4326).total_bounds
    return [round(float(v), round_to) for v in b]
import pyproj

print("Rutland parcels:", geographic_bbox("data/rutland_parcels.gpkg"))

href = item["assets"]["red"]["href"]
densified = geographic_bbox(href)
with rasterio.open(href) as src:
    to_wgs84 = pyproj.Transformer.from_crs(src.crs, 4326, always_xy=True)
    xs, ys = to_wgs84.transform([src.bounds.left, src.bounds.left, src.bounds.right, src.bounds.right],
                                [src.bounds.bottom, src.bounds.top, src.bounds.bottom, src.bounds.top])
corners = [round(min(xs), 6), round(min(ys), 6), round(max(xs), 6), round(max(ys), 6)]
print("S2 tile, corners only:", corners)
print("S2 tile, densified:   ", densified)
print("published item bbox:  ", item["bbox"])
print(f"north edge bulge: {densified[3] - corners[3]:.5f}° ≈ {(densified[3] - corners[3]) * 111_320:.0f} m")
try:
    transform_bounds("EPSG:32630", "EPSG:4326", 399960, 5790240, 509760, 5900040, densify_pts=0)
except Exception as exc:
    print("densify_pts=0:", exc)
Rutland parcels: [-0.835034, 52.522344, -0.419778, 52.762346]
S2 tile, corners only: [-4.49906, 52.25345, -2.853723, 53.249537]
S2 tile, densified:    [-4.49906, 52.25345, -2.853723, 53.249626]
published item bbox:   [-4.499044, 52.253459, -2.853738, 53.249528]
north edge bulge: 0.00009° ≈ 10 m
densify_pts=0: densify_pts must be at least 2 if the output is geograpic.

densify_pts=21 matters for rasters: a projected rectangle's geographic footprint is not a rectangle, and the corner-only transform under-reports the extent by the bulge in the middle of each edge.

Example 2 — the three dates, derived where possible

import datetime, pathlib, geopandas as gpd

def dates_for(record, data_path, date_column=None):
    out = {
        "content_date": record.get("content_date"),
        "published_date": record.get("published_date"),
        "processed_date": datetime.datetime.fromtimestamp(
            pathlib.Path(data_path).stat().st_mtime, datetime.timezone.utc
        ).date().isoformat(),
    }
    if date_column:
        col = gpd.read_file(data_path)[date_column].dropna()
        if len(col):
            out["data_date_range"] = [str(col.min())[:10], str(col.max())[:10]]
    return out
print(dates_for(records["old date"], "data/rutland_parcels.gpkg", date_column="VALIDFROM"))
{'content_date': '2021-03-31', 'published_date': '2026-09-01', 'processed_date': '2026-09-30', 'data_date_range': ['2001-08-18', '2023-03-01']}

Publishing the data's own date range next to the claimed content date makes any disagreement obvious to a reader, not only to a checker.

Example 3 — the gate, with the raster case

def assert_record_matches(record, data_path, tolerance_deg=0.01, date_column=None):
    problems = check_record(record, data_path, tolerance_deg)

    dates = dates_for(record, data_path, date_column)
    if dates.get("data_date_range"):
        newest = dates["data_date_range"][1]
        if str(record.get("content_date", "")) < newest:
            problems.append(f"content_date {record.get('content_date')} predates "
                            f"the newest feature {newest}")
    if record.get("published_date") and record.get("content_date"):
        if record["published_date"] < record["content_date"]:
            problems.append("published_date is before content_date")

    if problems:
        raise ValueError(f"{data_path}: metadata does not describe the data\n  " +
                         "\n  ".join(problems))
    return True
for name, record in records.items():
    try:
        assert_record_matches(record, "data/rutland_parcels.gpkg", date_column="VALIDFROM")
        print(f"{name:14} passes")
    except ValueError as exc:
        print(f"{name:14} {str(exc).splitlines()[1].strip()[:90]}")
derived        passes
reversed       bbox is not west, south, east, north: [-0.419778, 52.522344, -0.835034, 52.762346]
projected      bbox is outside geographic range — is it projected?
stale extent   bbox [-6.42, 49.86, 1.77, 55.81] does not match the data [-0.835, 52.5223, -0.4198, 52.762
wrong CRS      CRS EPSG:4326 does not match the data EPSG:27700
old date       content_date 2021-03-31 predates the newest feature 2023-03-01

check_record alone let the old content date through; the gate catches it because it reads the date column. Every record except the derived one now fails, each for the reason in its name.

Why validators cannot catch these

Why validators cannot catch these

JSON Schema and XML Schema describe documents. Whether the numbers in a document describe the file next to it is a question about two artefacts, which no schema language expresses. Every check in this guide compares a record against data, which means it has to be code you write.

Why extents go stale rather than being wrong from the start

Extents are usually correct when first written and then not updated: the dataset is clipped, filtered or extended, and the record is copied forward. Deriving the extent at write time eliminates the whole class, which is why it is the first recommendation in How to write a metadata record for a dataset in Python.

Why a projected bbox is so common

gdf.total_bounds returns the bounds in the frame's own CRS, and most working data is projected. A record that takes that value directly gets numbers like Rutland's [479631, 292484, 506328, 318933], which are valid numbers, pass every schema, and are not coordinates anywhere on Earth when read as degrees.

Why the corner transform under-reports a raster's footprint

Reprojecting only the four corners of a projected rectangle gives the bounding box of those four points. The actual footprint bulges outward along the edges, so the recorded extent can exclude real data. How much depends on the projection and the tile: for the 110 km Sentinel-2 tile in Example 1 the northern edge bulges 0.00009° — about 10 m — beyond its corners, and Earth Search's published item bbox is, to within a metre, the corner-only one — about 10 m short of the tile's northern edge. Small here, larger for bigger tiles or ones further from the projection's centre, and never zero; densifying the edges before transforming fixes it.

A projected raster footprint transformed from its corners against a densified edge. why densify the boundscorners onlydata falls outside the bboxdensify_pts=21nothing excludedA projected rectangle is not a geographic one spatialworkflow.io
The corner-only transform excludes real data along every edge.

Antimeridian extents, empty data and single points

  • Antimeridian extents wrap. West greater than east is correct there; handle it explicitly.
  • Empty datasets give NaN bounds. Guard before writing.
  • A single point has a zero-area bbox. Valid, and it breaks naive area checks.
  • Rounding is a tolerance. Six decimal places is about 0.1 m; compare accordingly.
  • Time zones shift dates. A timestamp near midnight can change date in UTC: 23:30 in New York is 04:30 the next day, and 00:30 in London in summer is 23:30 the day before.
  • Multi-layer files need per-layer extents as well as the container's.
  • Collections summarise item extents. Recompute them when items change.
  • State the tolerance in the record. "Extent matches data to 0.01°" is a claim; "extent" is not.

FAQ

Why does my STAC item never appear in search results?

Most often a reversed or projected bounding box. Both validate cleanly and neither intersects the query box a client sends.

Should the bbox be in the data's CRS?

No. STAC and GeoJSON bounding boxes are in EPSG:4326. Reproject before computing them.

How do I compute a raster's geographic footprint?

transform_bounds(src.crs, "EPSG:4326", *src.bounds, densify_pts=21). Transforming only the corners under-reports the footprint because the edges bulge.

Which date should the record show?

All three, separately: the content date the data describes, the publication date, and the processing date. Publishing one as all three is the usual error.

Why does my extent not match the data exactly?

Because it was rounded, or because it was copied from a previous version. Compare with a stated tolerance and derive it at write time.

Can a validator catch any of this?

No. Schemas check the document; these errors are disagreements between the document and the data, which only code you write can test.