Serialising DCAT-AP Records to JSON-LD and Turtle
Build the RDF graph once and serialise it into each format from that single graph, with the JSON-LD context pinned in the repository rather than resolved over the network — the graph is the record, and JSON-LD and Turtle are two renderings of it that must never be produced independently.
Teams reach for JSON-LD because portals ask for it and for Turtle because it is readable, and the failure mode is building each with its own code path. The two then diverge in ways that are invisible until a harvester reports that a dataset has two different licences depending on which representation it fetched. This guide sits under DCAT-AP spatial profile mapping, within Automated Metadata Generation & Schema Mapping.
Automated Python Implementation
#!/usr/bin/env python3
"""Serialise a DCAT-AP dataset record from one rdflib graph."""
import json
import pathlib
from rdflib import Graph, Literal, Namespace, URIRef
from rdflib.namespace import DCTERMS, FOAF, RDF, XSD
DCAT = Namespace("http://www.w3.org/ns/dcat#")
LOCN = Namespace("http://www.w3.org/ns/locn#")
# Pinned in the repository: a context fetched at runtime makes the output
# depend on somebody else's uptime and on the day it was generated.
CONTEXT_PATH = pathlib.Path("vendor/dcat-ap-context.jsonld")
def build_graph(record, base):
g = Graph()
g.bind("dcat", DCAT)
g.bind("dct", DCTERMS)
g.bind("locn", LOCN)
g.bind("foaf", FOAF)
dataset = URIRef(f"{base}/dataset/{record['id']}")
g.add((dataset, RDF.type, DCAT.Dataset))
g.add((dataset, DCTERMS.title, Literal(record["title"], lang="en")))
g.add((dataset, DCTERMS.description, Literal(record["description"], lang="en")))
g.add((dataset, DCTERMS.identifier, Literal(record["id"])))
g.add((dataset, DCTERMS.modified,
Literal(record["modified"], datatype=XSD.date)))
if record.get("licence_uri"):
g.add((dataset, DCTERMS.license, URIRef(record["licence_uri"])))
for keyword in record.get("keywords", []):
g.add((dataset, DCAT.keyword, Literal(keyword, lang="en")))
if record.get("bbox_wkt"):
location = URIRef(f"{dataset}/location")
g.add((dataset, DCTERMS.spatial, location))
g.add((location, RDF.type, DCTERMS.Location))
g.add((location, LOCN.geometry, Literal(
record["bbox_wkt"],
datatype=URIRef("http://www.opengis.net/ont/geosparql#wktLiteral"))))
for dist in record.get("distributions", []):
node = URIRef(f"{dataset}/distribution/{dist['id']}")
g.add((dataset, DCAT.distribution, node))
g.add((node, RDF.type, DCAT.Distribution))
g.add((node, DCAT.accessURL, URIRef(dist["access_url"])))
g.add((node, DCTERMS["format"], Literal(dist["format"])))
return g
def serialise_all(graph, out_dir, stem):
"""Write every representation from the same graph, in one pass."""
out = pathlib.Path(out_dir)
out.mkdir(parents=True, exist_ok=True)
context = json.loads(CONTEXT_PATH.read_text(encoding="utf-8"))
written = {}
written["ttl"] = out / f"{stem}.ttl"
graph.serialize(destination=str(written["ttl"]), format="turtle")
written["jsonld"] = out / f"{stem}.jsonld"
graph.serialize(destination=str(written["jsonld"]), format="json-ld",
context=context, auto_compact=True)
written["rdf"] = out / f"{stem}.rdf"
graph.serialize(destination=str(written["rdf"]), format="xml")
return written
Two choices in that code prevent the divergence the guide exists to warn about. The graph is built once and passed to every writer, so no format has its own assembly logic. And the JSON-LD context is read from a vendored file, which makes the output byte-reproducible and the build independent of network access — a property CI environments without egress require and which turns a context change into a reviewable diff.
Validation and Pipeline Integration
Assert that the serialisations agree, which is a graph comparison rather than a text comparison.
def test_serialisations_carry_identical_triples():
graph = build_graph(FIXTURE, "https://example.org")
turtle = Graph().parse(data=graph.serialize(format="turtle"), format="turtle")
jsonld = Graph().parse(data=graph.serialize(format="json-ld"), format="json-ld")
assert set(turtle) == set(jsonld)
def test_context_is_vendored():
assert CONTEXT_PATH.exists(), "the JSON-LD context must not be fetched at runtime"
def test_licence_is_a_uri_not_a_string():
graph = build_graph(FIXTURE, "https://example.org")
licences = list(graph.objects(predicate=DCTERMS.license))
assert licences and all(isinstance(o, URIRef) for o in licences)
The third test guards the most common DCAT-AP defect. dct:license expects a resource, and writing a literal such as "Open Government Licence v3" produces a graph that parses, serialises and tells a harvester nothing it can act on. Asserting the node type is one line and catches it permanently.
Run SHACL validation on the graph rather than on any serialisation. A shape report keyed to triples is meaningful for every format at once; running it three times against three files triples the runtime and can only produce the same answer.
Byte-Stability and Why It Matters
RDF has no canonical serialisation, and rdflib does not guarantee stable output ordering between runs. That means two builds of an unchanged record can produce two different files, and everything downstream that compares files — a git diff, a change-detection harvester, a content hash in an audit trail — sees a change that did not happen.
Three measures make the output stable enough to be useful.
Sort where the format allows it. Turtle output can be normalised by parsing and re-serialising with a sorted triple order; the cost is a second pass and the benefit is a diff that shows only real changes.
Hash the graph, not the file. Where the requirement is change detection rather than diff readability, a canonical hash over the sorted triple set is both stable and format-independent. It answers “did the record change” correctly even if the serialiser’s output ordering shifts after a library upgrade.
Pin rdflib and the context together. A library upgrade changes output formatting, and a context change changes JSON-LD compaction. Both look like content changes to anything watching the files, so both belong in the same pinned dependency set as the vendored context, updated deliberately rather than by a floating version specifier.
Blank Nodes, and Why to Avoid Them
rdflib will happily create a blank node whenever a structure needs a subject and none is supplied — a distribution, a location, a contact point. It is the path of least resistance and it makes the record harder to work with in three specific ways.
Blank nodes cannot be referenced from outside the document. A distribution that is a blank node cannot be linked to, cited, or reported against. When a link checker finds that an access URL is dead, it has no identifier for the thing that is broken beyond “somewhere in this dataset”.
They are not stable across serialisations. Blank node labels are local to a document, so _:b0 in one build may be _:b3 in the next. Any diff of the output shows spurious changes, and any consumer that stored the label has stored something meaningless.
They complicate SHACL reporting. A violation reported against a blank node tells the reader which shape failed and not which distribution, which for a dataset with four distributions is the difference between a fix and an investigation.
Minting URIs instead costs one line per node and a naming convention — the dataset URI plus a path segment and a stable local identifier is enough. The identifier should come from the source record rather than from an index, so that adding a distribution does not renumber the others. Where a genuinely anonymous structure is needed, a blank node is correct; that situation is rarer than the number of blank nodes in most generated catalogues suggests.
Long-Term Compliance Best Practices
- Never write one format from another. Converting Turtle to JSON-LD by string manipulation reintroduces exactly the divergence a shared graph prevents.
- Mint dataset URIs from a stable identifier. A URI derived from a title changes when the title is corrected, and every link to it breaks silently.
- Type every value deliberately. A date as a plain literal and a date typed as
xsd:dateare different triples, and only one of them sorts and filters correctly. - Keep the context vendored and versioned. It is part of the output’s meaning, not an implementation detail of the writer.
- Serialise all formats in one run. Producing them at different times is how they come to disagree.
Related
- DCAT-AP Spatial Profile Mapping — the profile these triples must satisfy
- Python Scripts for DCAT-AP Spatial Dataset Mapping — the extraction and graph construction layers this serialises
- ISO 19115 vs DCAT-AP: When to Use Each — deciding whether a dataset needs this profile at all
- Writing a GitHub Actions Workflow for DCAT Validation — running the SHACL check on every change