DCAT-AP Spatial Profile Mapping
Translating institutional geospatial catalog records into DCAT-AP Spatial-compliant RDF graphs is a prerequisite for publication to European national open data portals and cross-border DCAT-AP aggregators. The challenge is not format conversion alone — it is semantic alignment: bounding boxes must appear as WKT literals with explicit CRS declarations, licenses must resolve to recognized URI registries, and every mandatory property must survive round-trip harvesting. This page belongs to the Automated Metadata Generation & Schema Mapping pipeline family and covers the full workflow from raw source records through validated, publishable RDF.
Prerequisites
- Python 3.10+ with an isolated virtual environment (
python -m venv .venv) rdflib>=6.3— RDF graph construction and JSON-LD/Turtle serializationpydantic>=2.0— structural validation of extracted metadata before graph injectionlxml>=4.9— ISO 19115 XML and CSW GetRecords response parsingpyshacl>=0.25— SHACL shapes validation against DCAT-AP v3 reference profilesrequests>=2.31— CSW endpoint querying and URI resolution checks- Familiarity with the
dcat:,dct:,locn:, andgsp:namespace URIs - Read access to source catalogs: CSW endpoint, ISO 19115 XML exports, or FGDC XML files
- Environment variable
DCAT_AP_SHACL_PATHpointing to the downloaded DCAT-AP SHACL shapes file
Install the stack:
pip install "rdflib>=6.3" "pydantic>=2.0" "lxml>=4.9" "pyshacl>=0.25" "requests>=2.31"
Concept & Spec Reference
DCAT-AP (the Data Catalog Vocabulary Application Profile for European data portals) extends W3C DCAT 3 with mandatory and recommended properties specific to public sector data sharing. The Spatial extension adds geometry-level requirements that plain DCAT-AP omits.
Core namespaces
| Prefix | Namespace URI | Role in spatial mapping |
|---|---|---|
dcat |
http://www.w3.org/ns/dcat# |
Dataset, Distribution, Catalog, bbox, centroid |
dct |
http://purl.org/dc/terms/ |
title, description, publisher, license, spatial |
locn |
http://www.w3.org/ns/locn# |
geometry, Address |
gsp |
http://www.opengis.net/ont/geosparql# |
wktLiteral, asWKT |
dcatap |
http://data.europa.eu/r5r/ |
DCAT-AP-specific constraints |
xsd |
http://www.w3.org/2001/XMLSchema# |
datatype literals |
Mandatory properties for dcat:Dataset (DCAT-AP v3)
| Property | Cardinality | Notes |
|---|---|---|
dct:title |
1…n | One entry per language |
dct:description |
1…n | One entry per language |
dcat:distribution |
0…n | At least one recommended |
dct:publisher |
0…1 | Mandatory in most national profiles |
dct:license |
0…1 | Required at distribution level |
dcat:bbox |
0…1 | Spatial extension: WKT or GeoJSON literal |
dcat:centroid |
0…1 | Spatial extension: point geometry |
dct:spatial |
0…n | Named place URI (GeoNames, Nominatim) |
dct:conformsTo |
0…n | CRS URI, e.g. EPSG:4326 |
Namespace registry (production pattern)
Establish a deterministic registry at module load time to prevent URI collisions during serialization and SPARQL querying:
from rdflib import Namespace
NAMESPACES = {
"dcat": Namespace("http://www.w3.org/ns/dcat#"),
"dct": Namespace("http://purl.org/dc/terms/"),
"locn": Namespace("http://www.w3.org/ns/locn#"),
"gsp": Namespace("http://www.opengis.net/ont/geosparql#"),
"dcatap": Namespace("http://data.europa.eu/r5r/"),
"xsd": Namespace("http://www.w3.org/2001/XMLSchema#"),
"foaf": Namespace("http://xmlns.com/foaf/0.1/"),
}
Implementation Walkthrough
Step 1 — Inventory and field normalization
Extract source metadata from your catalog database, XML exports, or OGC CSW endpoints and normalize internal field names to DCAT-AP equivalents. For legacy ISO 19115 records, running them through ISO 19115 metadata template generation first ensures the spatial extent and metadata contact blocks are already in a structured form before DCAT-AP translation begins.
# normalize_fields.py — convert an ISO 19115 parsed dict to DCAT-AP field map
from lxml import etree
from typing import Optional
ISO_NS = {
"gmd": "http://www.isotc211.org/2005/gmd",
"gco": "http://www.isotc211.org/2005/gco",
}
def extract_iso19115_fields(xml_path: str) -> dict:
"""Return a normalized field map from an ISO 19115 XML file."""
tree = etree.parse(xml_path)
root = tree.getroot()
def text(xpath: str) -> Optional[str]:
nodes = root.xpath(xpath, namespaces=ISO_NS)
return nodes[0].strip() if nodes else None
return {
"title": text(".//gmd:title/gco:CharacterString/text()"),
"description": text(".//gmd:abstract/gco:CharacterString/text()"),
"publisher": text(".//gmd:organisationName/gco:CharacterString/text()"),
"license_uri": text(".//gmd:accessConstraints/gmd:MD_RestrictionCode/@codeListValue"),
"west": text(".//gmd:westBoundLongitude/gco:Decimal/text()"),
"east": text(".//gmd:eastBoundLongitude/gco:Decimal/text()"),
"south": text(".//gmd:southBoundLatitude/gco:Decimal/text()"),
"north": text(".//gmd:northBoundLatitude/gco:Decimal/text()"),
"access_url": text(".//gmd:linkage/gmd:URL/text()"),
"format": text(".//gmd:distributionFormat/gmd:MD_Format/gmd:name/gco:CharacterString/text()"),
}
For North American FGDC records, the FGDC to ISO 19115 conversion pipeline produces a compatible intermediate structure. Feed its output directly into the normalization step above rather than building a second FGDC-specific mapper.
Step 2 — Structural validation with Pydantic
Validate the normalized field map before touching the RDF graph. Catching missing titles, malformed URLs, or invalid license identifiers at this stage prevents silent omissions in the final serialization.
from pydantic import BaseModel, HttpUrl, Field, field_validator
from typing import Optional
import re
class SpatialDatasetModel(BaseModel):
title: str = Field(min_length=3)
description: str
publisher: str
license_uri: str # validated as URI below
west: float = Field(ge=-180, le=180)
east: float = Field(ge=-180, le=180)
south: float = Field(ge=-90, le=90)
north: float = Field(ge=-90, le=90)
access_url: HttpUrl
format: Optional[str] = None
@field_validator("license_uri")
@classmethod
def license_must_be_uri(cls, v: str) -> str:
if not re.match(r"^https?://", v):
raise ValueError(f"license_uri must be an absolute URI, got: {v!r}")
return v
@field_validator("east")
@classmethod
def east_gt_west(cls, v: float, info) -> float:
west = info.data.get("west")
if west is not None and v <= west:
raise ValueError(f"east ({v}) must be greater than west ({west})")
return v
Step 3 — Geometry and CRS resolution
DCAT-AP Spatial mandates dcat:bbox as a WKT literal with an explicit gsp:wktLiteral datatype. Convert the four bounding-box coordinates to a closed POLYGON and bind the CRS via dct:conformsTo.
from rdflib import Graph, URIRef, Literal, BNode
from rdflib.namespace import RDF, DCTERMS
def build_bbox_wkt(west: float, south: float, east: float, north: float) -> str:
"""Return a closed WKT POLYGON in WGS84 order (lon lat)."""
return (
f"POLYGON(({west} {south}, {east} {south}, "
f"{east} {north}, {west} {north}, {west} {south}))"
)
Always reproject to WGS84 (EPSG:4326) before serialization. If your source data uses a projected CRS — OSGB36, ETRS89-LAEA, or a national grid — use pyproj to convert the bounding box coordinates:
from pyproj import Transformer
def reproject_bbox(
west: float, south: float, east: float, north: float,
from_crs: str, to_crs: str = "EPSG:4326"
) -> tuple[float, float, float, float]:
"""Reproject a bounding box from from_crs to to_crs."""
transformer = Transformer.from_crs(from_crs, to_crs, always_xy=True)
w_lon, s_lat = transformer.transform(west, south)
e_lon, n_lat = transformer.transform(east, north)
return w_lon, s_lat, e_lon, n_lat
Step 4 — RDF graph construction and JSON-LD serialization
Bind namespaces first, then attach dataset-level triples, geometry, and distribution. Serialize to JSON-LD for REST API consumers and to Turtle for catalog harvesters in a single pass.
import uuid
from rdflib import Graph, URIRef, Literal, BNode
from rdflib.namespace import RDF, DCTERMS, FOAF, XSD
def build_dcat_ap_graph(
fields: dict,
base_uri: str = "https://data.example.gov/datasets/",
) -> Graph:
"""
Construct a DCAT-AP Spatial-compliant RDF graph from a normalized field dict.
Returns a populated rdflib.Graph ready for serialization or SHACL validation.
"""
DCAT = NAMESPACES["dcat"]
GSP = NAMESPACES["gsp"]
LOCN = NAMESPACES["locn"]
g = Graph()
for prefix, ns in NAMESPACES.items():
g.bind(prefix, ns)
dataset_uri = URIRef(f"{base_uri}{uuid.uuid4()}")
g.add((dataset_uri, RDF.type, DCAT.Dataset))
# Core Dublin Core properties
g.add((dataset_uri, DCTERMS.title, Literal(fields["title"], lang="en")))
g.add((dataset_uri, DCTERMS.description, Literal(fields["description"], lang="en")))
# Publisher as a blank-node Organization
publisher_bn = BNode()
g.add((publisher_bn, RDF.type, FOAF.Organization))
g.add((publisher_bn, FOAF.name, Literal(fields["publisher"])))
g.add((dataset_uri, DCTERMS.publisher, publisher_bn))
# License
g.add((dataset_uri, DCTERMS.license, URIRef(fields["license_uri"])))
# CRS conformance
g.add((dataset_uri, DCTERMS.conformsTo, URIRef("http://www.opengis.net/def/crs/EPSG/0/4326")))
# Bounding box as WKT literal
bbox_wkt = build_bbox_wkt(
float(fields["west"]), float(fields["south"]),
float(fields["east"]), float(fields["north"])
)
g.add((
dataset_uri,
DCAT.bbox,
Literal(bbox_wkt, datatype=GSP.wktLiteral),
))
# Distribution
dist_uri = URIRef(f"{base_uri}{uuid.uuid4()}/dist")
g.add((dist_uri, RDF.type, DCAT.Distribution))
g.add((dist_uri, DCAT.accessURL, URIRef(str(fields["access_url"]))))
if fields.get("format"):
g.add((dist_uri, DCTERMS.format, Literal(fields["format"])))
g.add((dataset_uri, DCAT.distribution, dist_uri))
return g
def serialize_graph(g: Graph, base_path: str) -> None:
"""Write Turtle and JSON-LD outputs side-by-side."""
g.serialize(destination=f"{base_path}.ttl", format="turtle")
g.serialize(destination=f"{base_path}.jsonld", format="json-ld", indent=2)
Validation & CI Integration
SHACL validation with pyshacl
The European Commission publishes reference SHACL shapes for DCAT-AP v3. Download the shapes file once and point DCAT_AP_SHACL_PATH at it. Run validation after every graph build — not only before publishing.
import os
from pyshacl import validate as shacl_validate
from rdflib import Graph
def validate_dcat_ap(data_graph: Graph) -> tuple[bool, str]:
"""
Validate data_graph against the DCAT-AP SHACL shapes.
Returns (conforms: bool, report_text: str).
"""
shapes_path = os.environ["DCAT_AP_SHACL_PATH"]
shapes_graph = Graph().parse(shapes_path, format="turtle")
conforms, report_graph, report_text = shacl_validate(
data_graph,
shacl_graph=shapes_graph,
inference="rdfs",
abort_on_first=False,
)
return conforms, report_text
Pre-commit hook
Add a lightweight validation script to .pre-commit-config.yaml so that mapping logic changes are checked before they reach the main branch:
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: dcat-ap-shacl-check
name: DCAT-AP SHACL validation
language: python
entry: python ci/validate_dcat_ap_sample.py
files: "^mapping/.*\\.py$"
additional_dependencies:
- "rdflib>=6.3"
- "pyshacl>=0.25"
ci/validate_dcat_ap_sample.py should parse a fixture JSON-LD file from tests/fixtures/, build the graph, and call validate_dcat_ap(), exiting non-zero on failure.
Pytest assertion pattern
import pytest
from rdflib import Graph
from mapping.dcat_ap_builder import build_dcat_ap_graph
from mapping.validation import validate_dcat_ap
VALID_FIELDS = {
"title": "Administrative Boundaries 2024",
"description": "Municipal boundaries for the metropolitan area.",
"publisher": "City GIS Unit",
"license_uri": "https://creativecommons.org/licenses/by/4.0/",
"west": "-1.5", "east": "1.5", "south": "48.5", "north": "51.5",
"access_url": "https://data.example.gov/layers/boundaries.gpkg",
"format": "GeoPackage",
}
def test_valid_graph_passes_shacl():
g = build_dcat_ap_graph(VALID_FIELDS)
conforms, report = validate_dcat_ap(g)
assert conforms, f"SHACL violations:\n{report}"
def test_missing_title_raises():
bad = {**VALID_FIELDS, "title": ""}
with pytest.raises(Exception):
build_dcat_ap_graph(bad)
Derivative & Lineage Management
When source datasets are transformed — reprojected to a national grid, clipped to an administrative boundary, joined with tabular attributes, or rasterized — the DCAT-AP graph must reflect those operations in its lineage metadata. Failing to update the dcat:bbox, dct:spatial, and dct:conformsTo triples after a CRS change is one of the most common compliance failures in automated pipelines.
Key obligations after transformation:
- Reprojection: Update
dcat:bboxto WGS84 coordinates of the reprojected extent. Retain the native CRS declaration underdct:conformsTo. Add adct:sourcetriple pointing to the pre-reprojection dataset URI. - Clip operations: Recompute the bounding box from the clipped geometry. The original
dcat:bboxfrom the source record is no longer valid and must not be inherited. - Format conversion: Create a new
dcat:Distributionnode with updateddct:formatanddcat:mediaType. Link it to the parent dataset viadcat:distribution. Do not modify the existing distribution node in place. - Join / attribute enrichment: Add a
dct:relationtriple linking the enriched dataset to its attribute source. If the join adds licensing obligations (e.g., merging CC-BY and ODbL layers), re-evaluatedct:licenseat the dataset level to reflect the most restrictive applicable term.
For lineage tracking at scale, consider the metadata schema validation and linting workflows, which provide automated diff-checking between pre- and post-transformation graphs to catch silent property deletions.
Tracking lineage in the RDF graph
from rdflib import Graph, URIRef, Literal
from rdflib.namespace import DCTERMS, PROV
def attach_lineage(
g: Graph,
derived_uri: URIRef,
source_uri: URIRef,
activity_label: str,
) -> None:
"""
Attach minimal PROV-O lineage triples to the derived dataset.
activity_label: human-readable description, e.g. 'Reprojected to EPSG:4326'.
"""
activity = URIRef(str(derived_uri) + "/provenance/activity")
g.add((derived_uri, PROV.wasDerivedFrom, source_uri))
g.add((derived_uri, PROV.wasGeneratedBy, activity))
g.add((activity, DCTERMS.description, Literal(activity_label, lang="en")))
Pitfalls & Resolution Table
| Pitfall | Root Cause | Resolution Strategy |
|---|---|---|
dcat:bbox literal rejected by harvester |
Missing gsp:wktLiteral datatype URI on the Literal |
Always pass datatype=NAMESPACES["gsp"].wktLiteral when constructing the Literal; never omit the datatype argument |
Harvest failure: unresolvable dct:license URI |
Legacy records store FGDC access constraint codes like "otherRestrictions" rather than URIs |
Map constraint codes to recognized EU Open Data Portal or Creative Commons URIs in the normalization step before graph construction |
SHACL failure: dct:title missing language tag |
Literal("My Title") without lang arg fails cardinality check in strict national profiles |
Always pass lang="en" (or appropriate BCP-47 tag); use multilingual Literals for multi-lingual portals |
| Bounding box coordinates reversed | Source records in lat/lon order; WKT expects lon/lat | Normalize coordinate order during the geometry resolution step; add an assertion that west < east and south < north |
| Duplicate dataset URIs on repeated pipeline runs | UUID-based URIs regenerated on each run; downstream triple stores accumulate duplicates | Derive dataset URIs deterministically from a stable identifier (e.g., hashlib.sha256(catalog_id + version)) rather than uuid.uuid4() |
JSON-LD @context not resolvable offline |
Default rdflib JSON-LD serializer emits remote context URIs that harvesters try to dereference |
Embed a local @context mapping in the serialized output or configure the harvester to cache context documents |
dcat:centroid absent but required by national profile |
Centroid property omitted during graph construction | Compute centroid from bbox midpoint: lon = (west + east) / 2, lat = (south + north) / 2; serialize as WKT POINT |
| Silent property loss after clip/reproject | Transformation scripts inherit source graph and overwrite only changed triples; stale triples persist | Re-validate the full graph after every transformation step; compare triple count before and after as a smoke test |
Related
- Automated Metadata Generation & Schema Mapping — parent topic covering the full pipeline context for this workflow
- ISO 19115 Metadata Template Generation — upstream normalization step that structures spatial extent and metadata contacts before DCAT-AP translation
- FGDC to ISO 19115 Conversion Pipelines — North American source format bridge that feeds directly into this mapping stage
- Metadata Schema Validation and Linting — downstream validation workflows for continuous compliance checking
- Python Scripts for DCAT-AP Spatial Dataset Mapping — companion guide with self-contained, runnable mapping scripts