How to write a metadata record for a dataset in Python
Problem statement
Writing metadata by hand fails for a reason that has nothing to do with discipline: most of the fields are facts about the data, so a human copying them in is a human introducing errors. Extents drift after a clip, feature counts go stale after a filter, and the CRS in the record is the one the dataset had two versions ago.
The fix is to split the record in two. The derivable half โ extent, CRS, schema, counts, checksums, geometry types โ is computed at write time from the file being written. The authored half โ title, what one feature is, lineage, licence, contact โ is a small YAML file a person maintains, and a gate refuses to publish without it.
This guide builds both halves and the gate.
Quick answer
import geopandas as gpd, hashlib, pathlib, datetime, json, yaml
def write_metadata(data_path, authored_path, out_path=None, layer=None):
gdf = gpd.read_file(data_path, layer=layer)
authored = yaml.safe_load(pathlib.Path(authored_path).read_text())
REQUIRED = ["title", "feature_definition", "content_date", "lineage", "licence", "contact"]
missing = [k for k in REQUIRED if not authored.get(k)]
if missing:
raise ValueError(f"{authored_path}: missing {missing}")
b = gdf.total_bounds
record = {
**authored,
"derived": {
"file": pathlib.Path(data_path).name,
"layer": layer,
"features": int(len(gdf)),
"geometry_types": sorted(gdf.geom_type.dropna().unique().tolist()),
"crs": gdf.crs.to_string() if gdf.crs is not None else None,
"bbox": [round(float(v), 6) for v in b],
"schema": {c: str(t) for c, t in gdf.dtypes.items() if c != gdf.geometry.name},
"null_counts": {c: int(gdf[c].isna().sum())
for c in gdf.columns if c != gdf.geometry.name},
"generated": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),
},
}
out = pathlib.Path(out_path or (str(data_path) + ".meta.json"))
out.write_text(json.dumps(record, indent=2, default=str))
return record
The derived block is regenerated on every write, so it cannot disagree with the data. The authored block is reviewed in a pull request like code.
Step-by-step solution
1. Write the authored half first, as YAML next to the data
title: Coastal flood risk zones, East Sussex
feature_definition: >
One polygon per contiguous modelled inundation area at the 1-in-200-year
return period. Areas under 100 mยฒ removed.
content_date: 2025-11-01
lineage:
- Environment Agency LiDAR Composite DTM 2024, 2 m
- pysheds 0.4 breach_depressions โ flowdir(D8) โ accumulation
- Return period surface from EA fluvial model v7, 200-year
licence: OGL-UK-3.0
attribution: Contains Environment Agency data ยฉ Crown copyright and database right 2025
contact: [email protected]
accuracy:
positional_rmse_m: 2.0
method: 43 GNSS control points, June 2025
YAML rather than JSON because a human edits it, and multi-line strings are readable.
2. Derive the rest at write time
Compute the derived block from the file you just wrote, not from the in-memory frame โ that is what catches a writer that dropped a column or promoted a geometry type.
3. Include the schema and the null counts
The schema is what breaks consumers when it changes, and the null counts tell a user whether a column is worth reading. Both are one line to compute and are the fields people thank you for.
4. Add a content checksum, not a byte checksum
A byte hash of a GeoPackage changes every time you write it. Hash the canonicalised data instead โ see How to checksum spatial datasets so you can prove they match.
5. Embed the record where the format allows
A sidecar is easy to separate from the data. Put at least the title, licence, version and content date inside the file as well, so a copy on somebody's laptop still knows what it is. How to store metadata inside a GeoPackage and How to embed provenance tags in a GeoTIFF cover the two formats that make this easy.
6. Emit the standard shapes from the same record
Do not author STAC or ISO directly; generate them. Metadata standards compared has the mapping.
7. Gate the publish step
The check is three lines and it is the only thing that keeps records complete once the deadline is close.
Code examples
Example 1 โ derive a raster's block too
import rasterio, numpy as np
def derived_raster(path):
with rasterio.open(path) as src:
stats = []
for i in range(1, src.count + 1):
band = src.read(i, masked=True)
stats.append({
"band": i,
"dtype": src.dtypes[i - 1],
"nodata": src.nodatavals[i - 1],
"valid_fraction": float(band.count() / band.size),
"min": None if band.count() == 0 else float(band.min()),
"max": None if band.count() == 0 else float(band.max()),
"mean": None if band.count() == 0 else float(band.mean()),
})
return {
"size": [src.width, src.height],
"bands": src.count,
"crs": src.crs.to_string() if src.crs else None,
"transform": list(src.transform)[:6],
"pixel_size": [abs(src.transform.a), abs(src.transform.e)],
"bbox": list(src.bounds),
"band_stats": stats,
"tags": src.tags(),
}
valid_fraction is the raster equivalent of a null count and answers "is this tile mostly nodata?" without opening it in a viewer.
Example 2 โ a schema block that can be diffed between versions
def schema_block(gdf):
out = {}
for col, dtype in gdf.dtypes.items():
if col == gdf.geometry.name:
continue
s = gdf[col]
entry = {"type": str(dtype), "nulls": int(s.isna().sum())}
if s.dtype == object or str(s.dtype) == "string":
uniques = s.dropna().unique()
if len(uniques) <= 25:
entry["values"] = sorted(map(str, uniques))
entry["max_length"] = int(s.dropna().astype(str).str.len().max() or 0)
elif "int" in str(s.dtype) or "float" in str(s.dtype):
entry["min"], entry["max"] = float(s.min()), float(s.max())
out[col] = entry
return out
Recording the value list for low-cardinality columns turns a code list into something a consumer can validate against, and makes a new category visible in a diff.
Example 3 โ the publish gate
REQUIRED = ["title", "feature_definition", "content_date", "lineage", "licence", "contact"]
KNOWN_LICENCES = {"CC0-1.0", "CC-BY-4.0", "CC-BY-SA-4.0", "ODbL-1.0",
"ODC-BY-1.0", "OGL-UK-3.0", "proprietary"}
def gate(record):
problems = []
problems += [f"missing {k}" for k in REQUIRED if not record.get(k)]
if record.get("licence") not in KNOWN_LICENCES:
problems.append(f"licence {record.get('licence')!r} is not a known identifier")
d = record.get("derived", {})
if not d.get("crs"):
problems.append("dataset has no CRS")
if d.get("features", 0) == 0:
problems.append("dataset is empty")
bbox = d.get("bbox") or [0, 0, 0, 0]
if bbox[0] > bbox[2] or bbox[1] > bbox[3]:
problems.append(f"bbox is not in west, south, east, north order: {bbox}")
if problems:
raise ValueError("metadata gate failed:\n " + "\n ".join(problems))
return True
The bbox order check is worth having explicitly: a STAC validator will accept a reversed bounding box without complaint.
Explanation
Why the derived block must come from the written file
The file is what ships, and writers change things: a shapefile truncates column names to ten characters, a GeoPackage promotes every Polygon to MultiPolygon, a GeoJSON round trip perturbs coordinates in the thirteenth decimal place. A record derived from the in-memory frame documents a dataset that nobody received.
Why the authored fields need a gate rather than a template
A template with empty fields ships with empty fields. The only mechanism that reliably produces complete records is one that stops the publish, because that is the only moment at which somebody has both the knowledge and the motivation to fill them in.
Why null counts and value lists earn their place
They are the two things a consumer would otherwise compute themselves, and they are the two that change silently between versions. A column that was 2% null and is now 60% null is a broken upstream join, and the metadata diff is where it shows up first.
Why YAML for the authored half
The authored half is a small document with multi-line prose in it, maintained by a person and reviewed in a pull request. JSON is unpleasant to write multi-line strings in; YAML is not. The generated record is JSON because programs read it.
Edge cases or notes
- Multi-layer files need a record per layer. Plus one for the container.
total_boundson an empty frame returns NaNs. Guard it.- Round the bbox deliberately. Six decimal places is ample and keeps the record diffable.
- Datetime objects are not JSON. Pass
default=stror convert explicitly. - Do not store credentials in lineage. Record the endpoint, not the token.
- Keep the authored file in version control even when the data is not.
- Regenerate on every release. A record that is older than the data is worse than none.
- A record is a deliverable. Ship it in the zip.
Internal links
- Spatial metadata explained: what a dataset must tell you โ which fields and why
- How to read the metadata already inside your spatial files โ what you can recover from existing files
- How to store metadata inside a GeoPackage โ embedding the record
- How to embed provenance tags in a GeoTIFF โ the raster equivalent
- How to checksum spatial datasets so you can prove they match โ the content hash field
- Metadata standards compared: ISO 19115, STAC and Frictionless โ emitting the standard shapes
- How to validate a GeoDataFrame against a schema before analysis โ the consumer side of the schema block
- Metadata extents and dates do not match the data โ what the derived block prevents
FAQ
How do I generate metadata for a spatial dataset in Python?
Compute the derivable fields โ extent, CRS, schema, counts, geometry types, checksum โ from the written file, and merge them with a small authored YAML file holding title, feature definition, lineage, licence and contact.
Which fields should a person write?
Title, what one feature represents, the content date, lineage, licence, attribution and a contact. Everything else can be derived.
Should the record be JSON or YAML?
Author in YAML because a human maintains it; generate JSON because programs consume it.
Why derive from the file rather than the DataFrame?
Because writers change things โ shapefiles truncate column names, GeoPackages promote geometry types โ and the record must describe what was actually shipped.
How do I stop records from going stale?
Regenerate the derived block on every write, and gate the publish step so an incomplete authored block raises rather than warns.
What checksum should go in the record?
A content hash of the canonicalised data. A byte hash of a GeoPackage or GeoJSON changes between identical writes.