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.

One graph, several serialisationsRecords are asserted into a single RDF graph, which is then serialised into Turtle, JSON-LD and RDF/XML by the same library.Recordtyped, validatedassertRDF graphtriples, namespaces boundserialiseThree writersTurtle, JSON-LD, RDF/XMLcheckSHACL reportrun on the graph, once
Every format is a view of the same triples, so they cannot disagree about what the record says.

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.

The three serialisations and what each is forTurtle, JSON-LD and RDF/XML compared by readability, consumer expectations and round-trip fidelity.Read byHuman-readableRound-tripsTurtlepeople, RDF toolsyesyesJSON-LDportals, web clientswith a compact contextyesRDF/XMLlegacy harvestersnoyes
All three carry identical triples. The differences are entirely about who reads them.

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.

A blank node against a minted URIBlank nodes and minted URIs compared by referenceability, stability across serialisations and how SHACL violations report against them.Blank nodeMinted URIReferenceable externallynoyesStable across buildslabel may changeyesSHACL violation names itas _:b3as the distributionSurvives a diff cleanlynoyes
One line of code separates these two columns.

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:date are 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.