Metadata standards compared: ISO 19115, STAC and Frictionless
On this page
Problem statement
Four standards want to describe your dataset, and they disagree about almost everything except that it has a bounding box. ISO 19115 is what national spatial data infrastructures require, STAC is what imagery catalogues and cloud-native workflows speak, DCAT is what open-data portals index, and Frictionless is what data engineers reach for when they want something readable.
Choosing badly is expensive in a specific way: you write a complete record in the wrong shape, and then discover that the catalogue you need to appear in cannot read it. Choosing at all is the mistake β the right move is to keep one internal record and generate whichever shapes you need.
This guide compares the four on what they express well, what they cost to produce, and which one to author in.
Quick answer
Author an internal record, emit the standards:
INTERNAL = {
"id": "hmlr-inspire-rutland",
"title": "INSPIRE Index Polygons, Rutland",
"description": "Freehold title extents registered with HM Land Registry, for Rutland.",
"bbox": [-0.835034, 52.522344, -0.419778, 52.762346],
"crs": "EPSG:27700",
"start_datetime": "2001-08-18T23:00:00Z", # oldest and newest VALIDFROM
"end_datetime": "2023-03-01T00:00:00Z",
"licence": "OGL-UK-3.0",
"keywords": ["cadastre", "land registration", "parcels"],
"lineage": ["HM Land Registry INSPIRE download, Rutland, GML", "read and written as GeoPackage with GeoPandas"],
"assets": {"data": {"href": "data/rutland_parcels.gpkg", "type": "application/geopackage+sqlite3"}},
"contact": "HM Land Registry",
}
to_stac(INTERNAL) # for a catalogue or a cloud-native pipeline
to_datapackage(INTERNAL) # for a data engineer
to_iso19115(INTERNAL) # for a national SDI, when required
That record describes a real dataset β HM Land Registry's INSPIRE Index Polygons for Rutland, 21,253 parcels under the Open Government Licence β and the examples below build it from the file and emit two of the shapes from it.
Every one of the four standards is a projection of roughly the same facts. The internal record is where the facts live.
Consumer before standard, what each is good at, field-level metadata
1. Identify the consumer before the standard
The question is never "which standard is best". It is "who has to read this?" β a national geoportal (ISO 19139 XML), an imagery catalogue or a cloud pipeline (STAC), a government open-data portal (DCAT), a colleague loading it with pandas (Frictionless), or a person (a README generated from the same record).
2. Know what each is actually good at
- ISO 19115 / 19139 β exhaustive, with real slots for lineage, quality, spatial representation and responsible parties. Verbose XML, and in practice most published records fill in ten of its hundreds of elements.
- STAC β built for spatio-temporal assets. Excellent at item-level search by time and footprint, at assets with roles and media types, and at extensions. Weak on lineage and rights beyond a licence string.
- DCAT β a catalogue vocabulary, not a spatial one. Good for discovery and for linking distributions; GeoDCAT-AP adds the spatial parts.
- Frictionless Data Package β a JSON descriptor with a table schema. Genuinely good at field-level metadata: types, constraints, units, descriptions. No spatial concepts of its own.
3. Put field-level metadata somewhere, whichever you choose
The single most useful thing missing from most spatial metadata is a description and a unit per column. STAC has no native slot for it; ISO has one nobody fills in; Frictionless is built around it. Whatever you publish, keep a table schema.
4. Use STAC if the data is tiled, dated or cloud-hosted
STAC's value is that a client can search by footprint and time and then read only the asset it needs. If you are publishing a series of rasters, or anything a pipeline will discover programmatically, STAC pays for itself. How to build a STAC item for your own raster covers the mechanics.
5. Use ISO only when something requires it
ISO 19139 records are expensive to author and almost never read by humans. Generate them from the internal record when a portal demands one, and do not treat the XML as the master copy.
6. Validate whatever you emit
Every standard has a validator, and each catches different things. Be aware of what they do not catch: Example 1 runs pystac's validator, which checks the item against the STAC JSON Schemas, on four versions of the Rutland item.
7. Keep the internal record in version control
It is a small text file that changes with the data and is reviewed with the code. That is where metadata quality actually comes from β not from the standard.
Emitting STAC and Frictionless from one record
The data used on this page
HM Land Registry's INSPIRE Index Polygons for Rutland β 21,253 freehold parcels in British National Grid β from which the internal record is derived rather than typed: extent from the geometry, time range from the VALIDFROM column.
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")
INTERNAL = {
"id": "hmlr-inspire-rutland",
"title": "INSPIRE Index Polygons, Rutland",
"description": "Freehold title extents registered with HM Land Registry, for Rutland.",
"bbox": [round(float(v), 6) for v in parcels.to_crs(4326).total_bounds],
"crs": parcels.crs.to_string(),
"start_datetime": parcels["VALIDFROM"].min().strftime("%Y-%m-%dT%H:%M:%SZ"),
"end_datetime": parcels["VALIDFROM"].max().strftime("%Y-%m-%dT%H:%M:%SZ"),
"licence": "OGL-UK-3.0",
"keywords": ["cadastre", "land registration", "parcels"],
"lineage": ["HM Land Registry INSPIRE download, Rutland, GML",
"read and written as GeoPackage with GeoPandas"],
"assets": {"data": {"href": "data/rutland_parcels.gpkg", "type": "application/geopackage+sqlite3"}},
"contact": "HM Land Registry",
}
print({k: INTERNAL[k] for k in ["bbox", "crs", "start_datetime", "end_datetime"]})
{'bbox': [-0.835034, 52.522344, -0.419778, 52.762346], 'crs': 'EPSG:27700', 'start_datetime': '2001-08-18T23:00:00Z', 'end_datetime': '2023-03-01T00:00:00Z'}
Example 1 β emit a STAC item from the internal record
import copy, datetime, pystac
def parse(value):
return datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
def to_stac(rec):
w, s, e, n = rec["bbox"]
item = pystac.Item(
id=rec["id"],
geometry={"type": "Polygon",
"coordinates": [[[w, s], [w, n], [e, n], [e, s], [w, s]]]},
bbox=rec["bbox"],
datetime=parse(rec["datetime"]) if rec.get("datetime") else None,
start_datetime=parse(rec["start_datetime"]) if rec.get("start_datetime") else None,
end_datetime=parse(rec["end_datetime"]) if rec.get("end_datetime") else None,
properties={"title": rec["title"], "description": rec["description"],
"license": rec["licence"], "keywords": rec["keywords"]},
)
for role, a in rec["assets"].items():
item.add_asset(role, pystac.Asset(href=a["href"], media_type=a["type"], roles=[role]))
return item
item = to_stac(INTERNAL)
good = item.to_dict()
variants = {"as built": good}
variants["west east of east"] = copy.deepcopy(good)
variants["west east of east"]["bbox"] = [good["bbox"][2], good["bbox"][1], good["bbox"][0], good["bbox"][3]]
variants["three-number bbox"] = copy.deepcopy(good)
variants["three-number bbox"]["bbox"] = good["bbox"][:3]
variants["date without time"] = copy.deepcopy(good)
variants["date without time"]["properties"]["start_datetime"] = "2001-08-18"
for name, d in variants.items():
try:
pystac.validation.validate_dict(d)
print(f"{name:20} valid")
except pystac.errors.STACValidationError as exc:
print(f"{name:20} invalid: {str(exc).splitlines()[0][:80]}")
as built valid
west east of east valid
three-number bbox invalid: Validation failed for Feature with ID hmlr-inspire-rutland against schema at htt
date without time invalid: Validation failed for Feature with ID hmlr-inspire-rutland against schema at htt
The validator catches a malformed bbox and a date where a timestamp belongs, and passes a box whose west edge is east of its east β four numbers are four numbers. The item has datetime null and a start and end instead, because a register of parcels valid since 2001 describes a range, not a moment; STAC requires the pair when datetime is null.
Example 2 β emit a Frictionless data package with a field schema
import json, geopandas as gpd
from pandas.api import types as ptypes
TYPE_MAP = {"int64": "integer", "float64": "number", "object": "string",
"string": "string", "bool": "boolean", "datetime64[ms]": "datetime"}
def to_datapackage(rec, gdf, units=None, descriptions=None):
units, descriptions = units or {}, descriptions or {}
fields = [{"name": c,
"type": TYPE_MAP.get(str(t), "any"),
"description": descriptions.get(c, ""),
"unit": units.get(c)}
for c, t in gdf.dtypes.items() if c != gdf.geometry.name]
return {
"name": rec["id"], "title": rec["title"], "description": rec["description"],
"licenses": [{"name": rec["licence"]}],
"spatial": {"bbox": rec["bbox"], "crs": rec["crs"]},
"resources": [{"name": rec["id"], "path": a["href"], "format": "gpkg",
"schema": {"fields": fields}}
for a in rec["assets"].values()],
}
package = to_datapackage(INTERNAL, parcels, descriptions={"INSPIREID": "INSPIRE identifier of the title extent"})
print("with the lookup table:", {f["name"]: f["type"] for f in package["resources"][0]["schema"]["fields"]})
def frictionless_type(dtype):
if ptypes.is_bool_dtype(dtype):
return "boolean"
if ptypes.is_integer_dtype(dtype):
return "integer"
if ptypes.is_float_dtype(dtype):
return "number"
if ptypes.is_datetime64_any_dtype(dtype):
return "datetime"
return "string"
print("by kind: ", {c: frictionless_type(t) for c, t in parcels.dtypes.items() if c != "geometry"})
with the lookup table: {'gml_id': 'any', 'INSPIREID': 'any', 'LABEL': 'any', 'NATIONALCADASTRALREFERENCE': 'any', 'VALIDFROM': 'any', 'BEGINLIFESPANVERSION': 'any'}
by kind: {'gml_id': 'string', 'INSPIREID': 'integer', 'LABEL': 'integer', 'NATIONALCADASTRALREFERENCE': 'integer', 'VALIDFROM': 'datetime', 'BEGINLIFESPANVERSION': 'datetime'}
The lookup table knew int64 but not int32, and not a datetime with a time zone or pandas 3's str β so every column in a real file came out as any, which is a schema that says nothing. Classify by kind, not by the dtype's exact name. The unit key is not part of the Frictionless spec's core, and it is the field that saves the most time downstream. Put it there anyway.
Example 3 β what the standards have in common
| Concept | ISO 19115 | STAC | DCAT | Frictionless |
|---|---|---|---|---|
| title | MD_Identification/citation/title |
properties.title |
dct:title |
title |
| description | abstract |
properties.description |
dct:description |
description |
| extent | EX_GeographicBoundingBox |
bbox + geometry |
dct:spatial |
(none) |
| time | EX_TemporalExtent |
properties.datetime |
dct:temporal |
(none) |
| licence | MD_LegalConstraints |
properties.license |
dct:license |
licenses[] |
| lineage | LI_Lineage |
(extension) | prov:wasDerivedFrom |
(none) |
| field schema | MD_FeatureCatalogue |
(none) | (none) | schema.fields[] |
| assets | MD_DigitalTransferOptions |
assets{} |
dcat:distribution |
resources[] |
The two gaps worth noticing: STAC has no native lineage, and only Frictionless has a real place for per-field metadata.
Why STAC won for imagery
Why STAC won for imagery and not for everything
STAC is optimised for one question β which assets cover this footprint and time β and it answers it extremely well, over static JSON on object storage with no server. That is exactly the shape of a satellite archive. It is less suited to a single vector dataset with a complicated processing history, where there is no time series to search and the interesting metadata is the lineage STAC does not model.
Why ISO records are usually empty
ISO 19115 has hundreds of elements, most optional, and the profile a portal enforces typically requires a dozen. The result is records that validate and say nothing. That is a fact about how the standard is used rather than a flaw in it, but it means an ISO record is rarely a source of truth about a dataset.
Why validators are necessary and insufficient
A JSON Schema check finds shape errors β a missing required property, a wrong type, a malformed datetime. It cannot find a bounding box in the wrong order, a licence that does not match what the data actually is, or a lineage that describes a different pipeline. Validation is a floor.
Why one internal record beats authoring in a standard
Standards change, and the set you must publish in changes faster. A small internal record in version control that emits STAC today and DCAT next year costs one function per target; a directory of hand-written XML costs a rewrite.
Collections against items, GeoDCAT-AP and INSPIRE
- STAC collections describe the series, items describe the assets. Do not put per-scene facts in the collection.
- GeoDCAT-AP exists for EU portals and maps DCAT onto INSPIRE requirements.
- INSPIRE has its own profile of ISO. Meeting ISO is not meeting INSPIRE.
- Frictionless has no CRS concept. Add one anyway; consumers need it.
- Keywords should come from a controlled vocabulary if a portal indexes them.
- A
datetimeof null needs a range. STAC requiresstart_datetimeandend_datetimeinstead. - Media types matter in STAC. The COG media type is what tells a client it can range-request.
- Do not hand-edit generated records. The edit is lost on the next run.
Internal links
- Spatial metadata explained: what a dataset must tell you β the fields all four standards express
- How to build a STAC item for your own raster β the STAC path in detail
- A STAC item is rejected by the validator β what validation does and does not catch
- How to write a metadata record for a dataset in Python β the internal record
- STAC catalogues explained β how the items are organised
- Provenance and lineage explained for spatial workflows β the gap in STAC
- Open data licences explained for spatial data β the licence field in each standard
- Metadata extents and dates do not match the data β the errors no validator catches
FAQ
Which metadata standard should I use for spatial data?
The one your consumer reads: STAC for imagery and cloud-native pipelines, ISO 19115 for national spatial data infrastructures, DCAT for open-data portals, Frictionless for tabular clarity. Author an internal record and emit whichever you need.
Is STAC a replacement for ISO 19115?
No. STAC models spatio-temporal assets and search; ISO models the full documentation of a dataset including lineage and quality. They overlap on about eight fields.
Where do I put per-column descriptions and units?
Frictionless has a native place for them; STAC and ISO effectively do not. Keep a field schema whatever else you publish β it is the metadata users miss most.
Does STAC handle vector data?
It can: an item can point at a GeoPackage or GeoParquet asset. It just adds less value when there is no time series or tiling to search across.
What does a STAC validator actually check?
JSON Schema conformance β required properties, types, the RFC 3339 datetime pattern, bbox length. It will not catch a reversed bounding box or a wrong licence.
Can I publish more than one standard for the same dataset?
Yes, and you usually should. Generate them all from one internal record so they cannot drift apart.