Metadata extents and dates do not match the data

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 because the item's footprint is somewhere off the coast of Africa.

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 {list(actual.round(4))}")

    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

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

Triage of five extent and date errors with the check that catches each.
Five errors, five assertions; none of them is a schema check.

Step-by-step solution

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 "surveyed_on" in gdf:
    latest = gdf["surveyed_on"].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.

Stack showing content date, publication date and processing date with what each answers.
Three dates, three questions; publishing one of them as all three is the usual error.

Code examples

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]

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

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

Explanation

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 [523000, 104000, 546000, 128000], which are valid numbers, pass every schema, and put the dataset in the Gulf of Guinea for any client that reads them 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 โ€” in a transverse Mercator at mid-latitudes by a noticeable fraction of a kilometre over a large tile โ€” so the recorded extent can exclude real data. Densifying the edges before transforming fixes it.

Two panels showing a raster footprint whose geographic bounding box misses the bulging edges when computed from corners only, and covers them when the bounds are densified.
The corner-only transform excludes real data along every edge.

Edge cases or notes

  • 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 local timestamp at 23:30 becomes the next day in UTC.
  • 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.