Validate DCAT-AP Metadata with SHACL Shapes Jump to heading
DCAT-AP is a contract: a national data portal that harvests your catalogue expects every record to carry a publisher, a resolvable license, at least one distribution, and a well-typed spatial extent. “Looks right” is not enough — the portal validates against the official DCAT-AP SHACL shapes and rejects records that fail, often with a terse error days after submission. Validating locally with the same shapes turns that late rejection into an immediate, actionable CI failure. This procedure models a record as RDF, runs it through pyshacl, and gates publication on the result. It is the validation half of Dataset Metadata & JSON-LD Publishing, which owns the manifest and the engine that emits the DCAT-AP node this page checks.
The steps follow the configure → execute → validate → log rhythm: load the record and the shapes, run the validator, interpret the report, and gate the build. SHACL validation is deterministic and offline once the shapes are pinned, so it runs in every pull request without network flakiness.
Prerequisites checklist Jump to heading
Confirm the toolchain and the pinned shapes before validating. The most common source of non-reproducible results is an unpinned shapes graph fetched live from the web, so vendor the shapes into the repository first.
Step 1: Load the DCAT-AP record as an RDF graph Jump to heading
SHACL validates RDF, not JSON, so first parse the metadata into an rdflib.Graph. rdflib reads JSON-LD directly when the json-ld parser plugin is available, which lets you validate the exact document the publishing engine emits without a separate conversion step.
# load_graph.py — rdflib >=7.0
from __future__ import annotations
import logging
from pathlib import Path
from rdflib import Graph
from rdflib.namespace import DCAT, DCTERMS
logger = logging.getLogger(__name__)
def load_record(record_path: Path) -> Graph:
"""Parse a DCAT-AP record (JSON-LD or Turtle) into an RDF graph."""
g = Graph()
fmt = "json-ld" if record_path.suffix in {".json", ".jsonld"} else "turtle"
g.parse(record_path, format=fmt)
# Fail fast if the record contains no dcat:Dataset at all.
datasets = list(g.subjects(predicate=None, object=DCAT.Dataset))
if not any(g.triples((s, None, DCAT.Dataset)) for s in g.subjects()):
# Fall back to an explicit type check.
typed = list(g.subjects(object=DCAT.Dataset))
if not typed:
raise ValueError(
f"{record_path.name} contains no dcat:Dataset node; "
"check the @type and namespace mapping in the emitter."
)
logger.info("Loaded %d triples from %s", len(g), record_path.name)
return g
If parsing raises or the graph has zero dcat:Dataset subjects, the problem is upstream in the emitter’s @context — a namespace typo that JSON-LD expansion would have caught. Validate expansion first, as described in the parent page’s CI section, then bring the record here.
Step 2: Load the official DCAT-AP SHACL shapes Jump to heading
The DCAT-AP shapes are published by the European Commission’s SEMIC programme as a set of Turtle files. Vendor a specific version into the repository and load it as its own graph; pinning is what makes validation reproducible, because the shapes evolve between DCAT-AP releases and a live fetch would silently change your acceptance criteria.
# load_shapes.py — rdflib >=7.0
from pathlib import Path
from rdflib import Graph
def load_shapes(shapes_path: Path) -> Graph:
"""Load the pinned DCAT-AP SHACL shapes graph."""
if not shapes_path.exists():
raise FileNotFoundError(
f"DCAT-AP shapes not vendored at {shapes_path}. "
"Download the pinned release and commit it; do not fetch live."
)
sg = Graph()
sg.parse(shapes_path, format="turtle")
return sg
Record the exact shapes version in the repository (in a SHAPES_VERSION constant or the filename) so the validation report can state which contract a record was judged against. A record that conforms to DCAT-AP 2.1.1 may fail DCAT-AP 3.0; the version is part of the compliance claim.
Step 3: Run pyshacl and capture the report Jump to heading
pyshacl.validate takes the data graph and the shapes graph and returns a triple: a boolean conforms, the report as its own RDF graph, and a human-readable text report. Capture all three — the boolean gates the build, the report graph is the machine-readable audit record, and the text is for the engineer reading the CI log.
# validate.py — pyshacl >=0.25, rdflib >=7.0
from __future__ import annotations
import logging
from dataclasses import dataclass
from pathlib import Path
from pyshacl import validate
from rdflib import Graph
logger = logging.getLogger(__name__)
@dataclass
class ValidationOutcome:
conforms: bool
report_graph: Graph
report_text: str
def validate_record(data_graph: Graph, shapes_graph: Graph) -> ValidationOutcome:
"""Validate a DCAT-AP record against the pinned SHACL shapes."""
conforms, report_graph, report_text = validate(
data_graph,
shacl_graph=shapes_graph,
inference="rdfs", # apply RDFS entailment so subclass types resolve
abort_on_first=False, # collect every violation, not just the first
meta_shacl=False, # shapes are pre-validated; skip meta-validation
advanced=True, # enable SHACL-AF features used by DCAT-AP shapes
)
if conforms:
logger.info("Record conforms to DCAT-AP shapes.")
else:
logger.error("Record does NOT conform; %d-char report follows.", len(report_text))
return ValidationOutcome(conforms, report_graph, report_text)
Set abort_on_first=False so a single run surfaces every problem; aborting on the first violation forces a slow fix-one-rerun loop. Keep inference="rdfs" on, because the DCAT-AP shapes target dcat:Dataset and your record may declare a more specific subclass that only resolves under RDFS entailment.
Step 4: Interpret and triage violations Jump to heading
A non-conforming report is an RDF graph of sh:ValidationResult nodes. Each result names a sh:focusNode (the offending subject, usually your dataset URI), a sh:resultPath (the property at fault, such as dcterms:license), a sh:resultSeverity, and a sh:resultMessage. Query these into a flat list so the CI log points straight at the field to fix.
# interpret.py — rdflib >=7.0
from rdflib import Graph
from rdflib.namespace import SH
def summarize_violations(report_graph: Graph) -> list[dict[str, str]]:
"""Flatten sh:ValidationResult nodes into an actionable list."""
rows: list[dict[str, str]] = []
for result in report_graph.subjects(predicate=None, object=SH.ValidationResult):
rows.append(
{
"focus_node": str(report_graph.value(result, SH.focusNode) or ""),
"path": str(report_graph.value(result, SH.resultPath) or ""),
"severity": str(report_graph.value(result, SH.resultSeverity) or "").rsplit("#", 1)[-1],
"message": str(report_graph.value(result, SH.resultMessage) or ""),
}
)
# Sort Violations before Warnings so the blocker is first in the log.
rows.sort(key=lambda r: (r["severity"] != "Violation", r["path"]))
return rows
Distinguish severities. A sh:Violation means the record breaks the DCAT-AP contract and a portal will reject it — this must block publication. A sh:Warning (for example a missing recommended dcat:keyword) signals a quality gap that should be tracked but does not have to block a merge. Mapping each violation back to a manifest field is exactly the field-name reconciliation discipline that Field Renaming & Type Coercion Rules applies to attribute data.
Step 5: Gate publication on conformance Jump to heading
Wire the outcome into a single gate that fails the build on any Violation and writes the report to an auditable path. This is the point where DCAT-AP validation becomes a hard precondition for publishing rather than advisory.
# gate.py — pyshacl >=0.25, rdflib >=7.0
import json
import sys
from pathlib import Path
from load_graph import load_record
from load_shapes import load_shapes
from validate import validate_record
from interpret import summarize_violations
def gate(record_path: Path, shapes_path: Path, report_path: Path) -> int:
data = load_record(record_path)
shapes = load_shapes(shapes_path)
outcome = validate_record(data, shapes)
rows = summarize_violations(outcome.report_graph)
report_path.write_text(json.dumps(rows, indent=2), encoding="utf-8")
violations = [r for r in rows if r["severity"] == "Violation"]
if violations:
for r in violations:
print(f"VIOLATION {r['path']} on {r['focus_node']}: {r['message']}")
return 1
print("DCAT-AP conformance: PASS")
return 0
if __name__ == "__main__":
sys.exit(gate(Path(sys.argv[1]), Path(sys.argv[2]), Path("dist/dcat-report.json")))
# .github/workflows/dcat-ap-gate.yml
name: DCAT-AP Conformance Gate
on: [pull_request]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: pip install "rdflib>=7.0" "pyshacl>=0.25"
- name: Validate DCAT-AP record against pinned shapes
run: python -m gate dist/parcels-2024.jsonld shapes/dcat-ap-3.0-shacl.ttl
Because the shapes are pinned and the record is deterministic, this gate is stable across runs — it fails only when the metadata genuinely breaks the contract. Aggregating this pass/fail into a repository-wide conformance signal, and blocking merges on drift over time, is the job of the CI Validation Scorecards cluster, which consumes the JSON report this gate writes.
Verification Jump to heading
Confirm the gate distinguishes a good record from a bad one — a validator that passes everything is worse than none. Prove it with a deliberately broken fixture:
# test_gate.py — pytest >=7, rdflib >=7.0, pyshacl >=0.25
from pathlib import Path
from load_graph import load_record
from load_shapes import load_shapes
from validate import validate_record
from interpret import summarize_violations
SHAPES = load_shapes(Path("shapes/dcat-ap-3.0-shacl.ttl"))
def test_valid_record_conforms():
outcome = validate_record(load_record(Path("tests/fixtures/valid.jsonld")), SHAPES)
assert outcome.conforms, outcome.report_text
def test_missing_license_is_a_violation():
"""A record with no dcterms:license must fail with a Violation."""
outcome = validate_record(load_record(Path("tests/fixtures/no_license.jsonld")), SHAPES)
assert not outcome.conforms
paths = {r["path"] for r in summarize_violations(outcome.report_graph)}
assert any("license" in p for p in paths), "license violation not reported"
A healthy run passes test_valid_record_conforms and fails the broken fixture with a license violation. On the CLI, python -m gate dist/parcels-2024.jsonld shapes/dcat-ap-3.0-shacl.ttl prints DCAT-AP conformance: PASS and exits 0 for a good record, and prints one VIOLATION line per broken field and exits 1 otherwise.
Troubleshooting Jump to heading
| Symptom | Likely cause | Fix |
|---|---|---|
conforms is True but the portal still rejects the record |
Validated against an older shapes version than the portal uses | Pin the shapes version the portal declares; re-run Step 2 with the matching release. |
Every dataset reports a sh:Violation on type |
dcat:Dataset type not asserted because of a @context namespace typo |
Fix the DCAT namespace in the emitter; confirm JSON-LD expansion first, then reload in Step 1. |
pyshacl raises ReportableRuntimeError on advanced features |
advanced=True omitted while the DCAT-AP shapes use SHACL-AF |
Pass advanced=True to validate as in Step 3. |
| License violation despite a license being present | License emitted as a literal string, not an IRI node | Emit dcterms:license as an @id object, not a plain string; re-check the manifest URI. |
| Validation passes locally, fails in CI | Shapes fetched live locally but vendored (stale) in CI, or vice versa | Vendor a single pinned shapes file and reference only that path everywhere. |
| Spatial extent flagged as invalid | dcterms:spatial missing the expected dcat:bbox or locn:geometry typing |
Emit the location node with the geometry serialization the pinned shapes require. |
Related Jump to heading
- Dataset Metadata & JSON-LD Publishing — the manifest and engine that emit the DCAT-AP record this procedure validates
- Emitting schema.org Dataset JSON-LD for Open Data Portals — the schema.org companion record harvested by Dataset Search
- CI Validation Scorecards — aggregating this conformance gate into a merge-blocking published scorecard
- Lineage Manifest Generation — the provenance the DCAT-AP
wasDerivedFromfield references - Geospatial Schema Architecture & Standards Mapping — the ISO 19115 metadata foundations DCAT-AP maps onto