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.
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.
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.
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.
Internal links
- How to write a metadata record for a dataset in Python โ deriving rather than authoring
- A STAC item is rejected by the validator โ what validation does catch
- How to build a STAC item for your own raster โ computing the footprint properly
- Spatial metadata explained: what a dataset must tell you โ the fields being checked
- Metadata standards compared: ISO 19115, STAC and Frictionless โ where each records an extent
- How to standardise dates in spatial data with Python โ producing consistent dates
- Projected versus geographic CRS explained โ why a projected bbox is wrong here
- How to validate pipeline inputs and outputs in Python โ where the gate belongs
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.