Publishing to CKAN with Automated DCAT Harvest Endpoints Jump to heading
CKAN is the most common home for government spatial data and its web form is the most common way data gets into it — which means the metadata a harvester sees is typically whatever someone typed while thinking about something else. Driving CKAN from the publication manifest instead makes the catalogue a derived artefact: reproducible, diffable, and identical whether the release was made by a person or by a scheduled job. This procedure implements the portal-facing half of open data portal publication workflows, inside Geospatial Compliance Reporting & Audit Trails.
The steps map to configure (Step 1, the object model), execute (Steps 2–3, dataset and resources), validate (Step 4, the DCAT extras), and log (Step 5, verification of what harvesters actually see).
Prerequisites checklist Jump to heading
Step 1: Decide the CKAN object model before writing code Jump to heading
CKAN’s model is organization → dataset (package) → resource, and DCAT-AP’s is catalog → dataset → distribution. The mapping that works and keeps citations stable is:
| Manifest concept | CKAN object | Note |
|---|---|---|
dataset_id |
package name |
The URL slug; permanent, never versioned |
| Release version | package version field, plus a resource-level tag |
Not in the slug, or every release breaks the landing page |
| Distribution | resource | One per format, each with its own checksum |
| Conformance report | resource with format: JSON |
A first-class artefact, not a link buried in the notes |
| Landing page | package URL | CKAN provides it; do not duplicate it in an extra |
The temptation is to make each release its own CKAN dataset so the version appears in the URL. Resist it: consumers bookmark and cite the dataset, and a new slug every quarter fragments the audience and orphans every citation. Version at the resource level, keep old resources listed, and let the package remain the permanent identity.
Step 2: Upsert the dataset idempotently Jump to heading
# publish/ckan.py — requests >=2.31 — Python 3.10+
import logging
import requests
logger = logging.getLogger("publish.ckan")
TIMEOUT = 60
class Ckan:
def __init__(self, base_url: str, token: str):
self.base = base_url.rstrip("/")
self.session = requests.Session()
self.session.headers["Authorization"] = token
def call(self, action: str, payload: dict) -> dict:
response = self.session.post(f"{self.base}/api/3/action/{action}",
json=payload, timeout=TIMEOUT)
if response.status_code == 404 and action == "package_show":
return {}
response.raise_for_status()
body = response.json()
if not body.get("success"):
raise RuntimeError(f"{action} failed: {body.get('error')}")
return body["result"]
def upsert_dataset(ckan: Ckan, manifest: dict) -> dict:
meta = manifest["metadata"]
payload = {
"name": manifest["dataset_id"], # stable slug
"title": meta["title"],
"notes": meta["description"],
"license_id": meta["licence"],
"owner_org": meta["organization"],
"version": manifest["release"]["version"],
"maintainer": meta["contact_point"]["name"],
"maintainer_email": meta["contact_point"]["email"],
"extras": dcat_extras(manifest), # Step 4
}
existing = ckan.call("package_show", {"id": manifest["dataset_id"]})
if existing:
payload["id"] = existing["id"]
result = ckan.call("package_update", payload)
logger.info("updated dataset %s to version %s", payload["name"], payload["version"])
else:
result = ckan.call("package_create", payload)
logger.info("created dataset %s", payload["name"])
return result
package_update replaces the whole package, so any field omitted from the payload is cleared. That is a feature when the manifest is the source of truth — it means a field deleted from the manifest disappears from the portal, and the portal cannot drift — but it destroys anything a human added through the web interface. Say so explicitly in the manifest’s documentation: the portal record is generated, and manual edits will be overwritten on the next release.
Step 3: Create or update resources with their checksums Jump to heading
# publish/ckan.py — Python 3.10+
from pathlib import Path
def upsert_resources(ckan: Ckan, package: dict, manifest: dict) -> list[dict]:
existing = {r["name"]: r for r in package.get("resources", [])}
results = []
for dist in manifest["distributions"]:
path = Path(dist["path"])
name = f"{dist['format']} — {manifest['release']['version']}"
payload = {
"package_id": package["id"],
"name": name,
"format": dist["format"],
"mimetype": dist["media_type"],
"size": path.stat().st_size,
"hash": dist["checksum_value"],
"hash_algorithm": "sha256",
"description": dist.get("label", ""),
}
if name in existing:
payload["id"] = existing[name]["id"]
with path.open("rb") as handle:
results.append(ckan.upload("resource_update", payload, handle))
else:
with path.open("rb") as handle:
results.append(ckan.upload("resource_create", payload, handle))
logger.info("resource %s (%s, %d bytes)", name, dist["checksum_value"][:12],
payload["size"])
return results
Naming the resource with its release version is what makes the history legible on the portal page: a consumer can see that GeoPackage — 2026.2 is current and GeoPackage — 2026.1 is the previous release, without reading a changelog. Publishing hash and hash_algorithm gives a consumer the means to verify the download, and ckanext-dcat maps both into spdx:checksum in the RDF.
Step 4: Populate the extras that DCAT-AP harvesters actually read Jump to heading
# publish/ckan.py — Python 3.10+
import json
def dcat_extras(manifest: dict) -> list[dict]:
meta = manifest["metadata"]
minx, miny, maxx, maxy = meta["spatial_extent"]
return [
# ckanext-spatial reads this GeoJSON to build the spatial index and dct:spatial.
{"key": "spatial", "value": json.dumps({
"type": "Polygon",
"coordinates": [[[minx, miny], [maxx, miny], [maxx, maxy], [minx, maxy], [minx, miny]]],
})},
{"key": "spatial_uri", "value": meta.get("spatial_uri", "")},
{"key": "temporal_start", "value": meta["temporal_extent"][0]},
{"key": "temporal_end", "value": meta["temporal_extent"][1]},
{"key": "conforms_to", "value": manifest["metadata"]["conformance_report_uri"]},
{"key": "access_rights", "value": meta.get("access_rights", "public")},
{"key": "provenance", "value": meta["provenance_statement"]},
{"key": "theme", "value": "http://publications.europa.eu/resource/authority/data-theme/REGI"},
]
Two of these are worth insisting on. The spatial extra must be a GeoJSON geometry in EPSG:4326 with longitude first — a bounding box typed in the projected CRS of the data, or with the axes swapped, produces a dataset that indexes at the wrong place on the map and never appears in a spatial search of its own area. And provenance is where the pipeline earns credit for everything upstream: a one-sentence statement naming the source, the transformation and the conformance report, generated from the lineage manifest rather than typed.
Step 5: Verify what a harvester will actually see Jump to heading
Publishing is not finished when the API returns 200. What matters is the RDF a harvester fetches.
# publish/verify.py — rdflib >=7.0, requests >=2.31 — Python 3.10+
from rdflib import Graph, Namespace, URIRef
from rdflib.namespace import DCAT, DCTERMS
def verify_dcat(base_url: str, dataset_id: str, manifest: dict) -> None:
graph = Graph()
graph.parse(f"{base_url}/dataset/{dataset_id}.rdf", format="xml")
datasets = list(graph.subjects(predicate=None, object=DCAT.Dataset))
assert datasets, "no dcat:Dataset in the published RDF"
subject = datasets[0]
distributions = list(graph.objects(subject, DCAT.distribution))
assert len(distributions) == len(manifest["distributions"]), (
f"{len(distributions)} distributions in RDF, "
f"{len(manifest['distributions'])} in the manifest"
)
for predicate in (DCTERMS.title, DCTERMS.license, DCTERMS.spatial, DCTERMS.publisher):
assert (subject, predicate, None) in graph, f"missing {predicate} in published RDF"
Run this as the last step of the release job, and fail the release if it fails. The two failures it catches most often are a distribution that uploaded but did not attach — leaving the RDF advertising fewer files than exist — and a licence that CKAN did not recognize, which silently produces a dataset with no dcterms:license and therefore no legal reuse terms for any harvester downstream.
Verification Jump to heading
# The catalogue endpoint a harvester polls
curl -s https://data.example.gov/catalog.rdf | grep -c "dcat:Dataset"
# One dataset, as RDF and as DCAT-AP JSON-LD
curl -s https://data.example.gov/dataset/cadastral-parcels.rdf | head -40
curl -s https://data.example.gov/dataset/cadastral-parcels.jsonld | jq '.["@graph"][0].title'
A successful release logs the release version, every resource with a truncated hash, and the RDF assertion result:
INFO publish.ckan updated dataset cadastral-parcels to version 2026.2
INFO publish.ckan resource GeoPackage — 2026.2 (9f2a41c0b8d3, 481,203,776 bytes)
INFO publish.ckan resource GeoJSON — 2026.2 (2c7e08ab19f5, 1,204,882,001 bytes)
INFO publish.verify dcat: 3 distributions, licence and spatial present — ok
Troubleshooting Jump to heading
| Symptom | Likely cause | Fix |
|---|---|---|
| Dataset does not appear in a spatial search | spatial extra in the projected CRS, or axes swapped |
Emit GeoJSON in EPSG:4326, longitude first; ckanext-spatial does not reproject |
| Harvester reports no licence | license_id not one of CKAN’s registered ids |
Use an id from the portal’s licence list, not a free-text name |
| Manual portal edits keep disappearing | package_update replaces the whole package, as designed |
Move the field into the manifest; the portal record is generated |
| Duplicate resources after every release | Resource matching by name failed because the name embeds a timestamp | Match on a stable resource name and carry the version in the label |
| RDF shows fewer distributions than uploaded | A resource upload failed after the dataset update | Fail the release on the RDF assertion; re-run — the upsert is idempotent |
| Harvest is stale for days | The harvester polls on its own schedule | Publish the catalogue endpoint update in the same run and, where supported, trigger the harvest explicitly |
Related Jump to heading
- Open Data Portal Publication Workflows — the parent stage, its manifest and the immutable-release rule
- Scheduling Metadata Refresh and Retention Sweeps — keeping the catalogue current between releases
- Emitting schema.org Dataset JSON-LD for Open Data Portals — the other serialization of the same metadata
- Validating DCAT-AP Metadata with SHACL Shapes — checking the RDF against the profile before it is harvested