OGC API - Records and Catalog Modernisation

CSW has been the interoperable way to search spatial metadata for two decades, and it shows: an XML POST body carrying a filter encoding, a numeric paging cursor, and records that arrive as Dublin Core or ISO 19139 documents. OGC API - Records re-expresses the same catalogue as resources — a landing page, collections, and records that are GeoJSON Features — with CQL2 for filtering and link relations instead of an envelope. This guide covers what actually changes, what does not, and how to run both without maintaining two catalogues.

Prerequisites & Architecture Context

You need an existing metadata store — records conforming to ISO 19115, DCAT-AP or STAC — plus a spatial and temporal index over it. On the client side, requests and the CQL2 builder from the features filtering guide are sufficient; nothing about Records needs a specialised library the way CSW needed OWSLib.

The important architectural point is that Records is a view, not a store. It shares its resource model with OGC API - Features — a record is a Feature, a catalogue is a collection — which means an existing Features implementation can serve records with very little new machinery. That is also what makes running both protocols reasonable: one store, one query layer, two encodings on top.

The same catalogue, re-expressed as resources A six-row grid comparing CSW 2.0.2 with OGC API - Records. Service discovery moves from a capabilities document to a landing page and conformance declaration; search moves from a GetRecords POST to a GET on an items path; the filter language becomes CQL2; paging moves from a numeric cursor to a next link; and a record becomes a GeoJSON Feature rather than a Dublin Core or ISO XML document. Concern CSW 2.0.2 OGC API - Records Discovery GetCapabilities XML landing page + /conformance Search GetRecords POST GET /collections/{id}/items Query language fes filter / CQL CQL2 text or JSON Paging startPosition + nextRecord limit + a next link Record csw:Record or gmd:MD_Metadata GeoJSON Feature Identity dc:identifier the Feature id

What does not change is the metadata itself. A record’s title, abstract, keywords, extent and lineage are the same facts whether they are serialised as ISO 19139 XML or as a GeoJSON Feature with a properties object. Migrating protocol is a serialisation and transport change; migrating metadata model is a different and much larger project, and conflating the two is why catalogue modernisations stall.

Specification Deep-Dive: The Resource Model

A Records service is a landing page at the root, a conformance declaration, one or more catalogues exposed as collections, and records exposed as items within them.

Four levels, and the links are the substance Four stacked levels of an OGC API - Records service. The landing page links to the conformance declaration, the collections and the API description. A catalogue is a collection of records. A record is a GeoJSON Feature carrying temporal extent, geometry and links. The link relations are what tie a record to the data it describes and to the services that serve it. Landing page links to conformance, collections and the API description Catalogue (a collection) one searchable set of records Record (a Feature) GeoJSON with time, geometry and links Association links rel values tie a record to its data and its service

Records are GeoJSON Features. The geometry is the record’s spatial extent — usually a bounding box polygon rather than the data’s true footprint — and properties carries the descriptive fields: title, description, created, updated, keywords, themes, providers, license. A time member carries the temporal extent. This is the same shape a STAC Item uses, deliberately.

Links are the substance, not decoration. A record’s links array is what connects it to the world: rel="item" points at the data itself, rel="service" at an OGC service that serves it, rel="alternate" at the same record in another encoding, rel="describedby" at a schema. A record without links is a description of something a client cannot reach, which is the most common defect in migrated catalogues — the ISO source held the linkage in CI_OnlineResource elements and the conversion dropped them.

Search is a GET, and that changes what is cacheable. A CSW GetRecords is a POST with an XML body, so it caches nowhere. A Records item search is a URL, so an intermediary caches it, a user bookmarks it, and a support request can quote it. For a public catalogue that is a substantial operational difference.

Filtering uses CQL2. The same language OGC API - Features adopted, in either its text or JSON serialisation, with the same conformance-declaration discipline: a server that does not declare the filter class ignores the parameter rather than rejecting it, and returns every record. That failure mode — a silent superset — is worth guarding against explicitly in any client.

Python Implementation: One Store, Two Protocols

The pattern that keeps a migration tractable is a single record model with two serialisers, so CSW and Records are two views of the same query rather than two catalogues that drift.

from __future__ import annotations

from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
from xml.etree import ElementTree as ET

DC = "http://purl.org/dc/elements/1.1/"
CSW = "http://www.opengis.net/cat/csw/2.0.2"
OWS = "http://www.opengis.net/ows"

@dataclass
class Link:
    href: str
    rel: str
    type: str | None = None
    title: str | None = None

    def as_json(self) -> dict[str, Any]:
        return {k: v for k, v in
                {"href": self.href, "rel": self.rel,
                 "type": self.type, "title": self.title}.items() if v}

@dataclass
class Record:
    """One catalogue record, independent of how it is serialised."""
    identifier: str
    title: str
    description: str
    updated: datetime
    bbox: tuple[float, float, float, float] | None = None
    keywords: list[str] = field(default_factory=list)
    links: list[Link] = field(default_factory=list)
    record_type: str = "dataset"

    # ── OGC API - Records ────────────────────────────────────────────
    def as_feature(self) -> dict[str, Any]:
        geometry = None
        if self.bbox:
            west, south, east, north = self.bbox
            geometry = {"type": "Polygon", "coordinates": [[
                [west, south], [east, south], [east, north],
                [west, north], [west, south]]]}
        return {
            "type": "Feature",
            "id": self.identifier,
            "geometry": geometry,
            "properties": {
                "type": self.record_type,
                "title": self.title,
                "description": self.description,
                "updated": self.updated.isoformat(),
                "keywords": list(self.keywords),
            },
            "links": [link.as_json() for link in self.links],
        }

    # ── CSW 2.0.2 ────────────────────────────────────────────────────
    def as_csw_record(self) -> ET.Element:
        record = ET.Element(f"{{{CSW}}}Record")
        ET.SubElement(record, f"{{{DC}}}identifier").text = self.identifier
        ET.SubElement(record, f"{{{DC}}}title").text = self.title
        ET.SubElement(record, f"{{{DC}}}type").text = self.record_type
        for keyword in self.keywords:
            ET.SubElement(record, f"{{{DC}}}subject").text = keyword
        ET.SubElement(record, f"{{{DC}}}abstract").text = self.description
        ET.SubElement(record, f"{{{DC}}}modified").text = self.updated.isoformat()
        # Links become dc:references, which is the only place CSW's Dublin
        # Core profile can carry them.
        for link in self.links:
            ET.SubElement(record, f"{{{DC}}}references",
                          scheme=link.rel).text = link.href
        if self.bbox:
            west, south, east, north = self.bbox
            box = ET.SubElement(record, f"{{{OWS}}}BoundingBox",
                                crs="urn:x-ogc:def:crs:EPSG:6.11:4326")
            ET.SubElement(box, f"{{{OWS}}}LowerCorner").text = f"{south} {west}"
            ET.SubElement(box, f"{{{OWS}}}UpperCorner").text = f"{north} {east}"
        return record

def records_response(records: list[Record], limit: int, offset: int,
                     total: int, base_url: str, collection: str) -> dict[str, Any]:
    """An OGC API - Records items page, with the next link that drives paging."""
    links = [{"href": f"{base_url}/collections/{collection}/items"
                      f"?limit={limit}&offset={offset}",
              "rel": "self", "type": "application/geo+json"}]
    if offset + limit < total:
        links.append({"href": f"{base_url}/collections/{collection}/items"
                              f"?limit={limit}&offset={offset + limit}",
                      "rel": "next", "type": "application/geo+json"})
    return {
        "type": "FeatureCollection",
        "features": [r.as_feature() for r in records],
        "numberReturned": len(records),
        "numberMatched": total,
        "links": links,
    }

Note what the CSW serialiser has to do with links: Dublin Core has no link array, so associations are flattened into dc:references with a scheme attribute. That lossy step is precisely why a records-first migration is easier than a CSW-first one — going from a rich model to a poorer encoding is mechanical, while recovering structure that was flattened is not.

Error Handling & Edge Cases

An undeclared filter class returns everything. As with CQL2 against Features, a Records service that has not declared the filter conformance class ignores a filter parameter and answers with an unfiltered page. A client that trusts the response silently processes the entire catalogue. Check /conformance before sending a filter and refuse to run if the class is absent.

The answer is usually both, for a while A decision diamond on catalogue protocol choice. A regulated environment still needs CSW because INSPIRE and national harvesters speak it; web and application clients are far better served by Records; an existing CSW estate is best migrated records-first with CSW proxied over the same store; and a greenfield internal catalogue has no reason to implement CSW at all. Should a new catalogue be CSW, OGC API - Records, or both? Both INSPIRE harvesters speak CSW Records JSON, links, no envelope Both, records-first proxy CSW over the same store Records nothing depends on CSW regulated harvesting web and app clients existing CSW estate greenfield internal

A record’s geometry is its extent, not its footprint. Most catalogues publish a bounding box polygon, so a spatial search returns records whose extent intersects the query, which for a national dataset means almost every query matches it. That is correct behaviour and frequently surprising; surfacing the distinction in a user interface avoids a class of “why is this in my results” question.

Identifiers must survive the migration. Downstream systems reference records by their CSW dc:identifier. If the Records service mints new identifiers, every existing citation breaks. Carrying the original identifier as the Feature id is the only safe choice, however unattractive it looks.

Empty results are ambiguous. As with CSW, zero records can mean the query matched nothing or that the request was malformed in a way the server tolerated. Asserting a non-zero count for a known-good control query, on every run, is the cheap guard.

Testing & Compliance Verification

The valuable tests during a migration are equivalence tests: the same logical query answered by both protocols must return the same identifiers.

def test_protocols_agree(csw_client, records_client):
    """The same query, both ways, must select the same records."""
    csw_ids = set(csw_client.search(keyword="flood", bbox=(5, 45, 11, 48)))
    records_ids = {
        feature["id"]
        for feature in records_client.search(
            filter_text="'flood' IN keywords", bbox=(5, 45, 11, 48))}

    assert csw_ids == records_ids, (
        f"only in CSW: {sorted(csw_ids - records_ids)}; "
        f"only in Records: {sorted(records_ids - csw_ids)}")

def test_every_record_has_a_reachable_link(records_client):
    missing = [f["id"] for f in records_client.all_records()
               if not any(l["rel"] in {"item", "service"} for l in f["links"])]
    assert not missing, f"records with no data link: {missing[:10]}"

The link test is the one that catches the most real migration damage, because dropped associations produce a catalogue that validates perfectly and describes things nobody can reach. The OGC also publishes a Records conformance suite, which validates the resource structure and the declared conformance classes but says nothing about whether your records point anywhere useful.

Performance & Scaling Notes

GET search is cacheable; POST search is not. Moving discovery queries onto URLs means an intermediary can cache the common ones, which for a public catalogue whose front page issues the same few queries repeatedly is most of the traffic.

Paging by link, not by offset. A deep offset forces the store to count and skip, and on a catalogue being written to concurrently it also produces duplicates and gaps. Following rel="next" lets the server implement a keyset cursor without the client caring — the discipline described in Paginating OGC API - Features Collections in Python.

Index what CQL2 can filter on. Full-text search over titles and abstracts, a spatial index over extents, and a temporal index over the update timestamp cover almost every real query. Without them a filter is a sequential scan over the whole catalogue, and a catalogue is the one service where users expect sub-second responses.

Harvesting stays incremental. The watermark discipline from Automated Metadata Harvesting Workflows applies unchanged: filter on the updated timestamp, page with links, and advance the watermark only after a clean run. Only the request syntax changes.

Frequently Asked Questions

Does OGC API - Records replace CSW?

Eventually, and not yet. INSPIRE and several national harvesting infrastructures still speak CSW, so a catalogue with regulatory obligations needs both for some years. The practical arrangement is one store and one query layer with two serialisers, which costs far less than maintaining two catalogues and keeps them from diverging.

Is a Records record the same as a STAC Item?

They share a shape deliberately — both are GeoJSON Features with properties and links — but they describe different things. A STAC Item describes one asset such as a scene, with band-level detail; a Records record describes a resource in a catalogue, which may be a dataset, a service or another catalogue. A STAC Item can be exposed as a record, which is a common and sensible arrangement.

What is the hardest part of migrating from CSW?

Preserving associations. ISO 19139 holds linkage in CI_OnlineResource elements nested inside distribution information, and a conversion that maps the descriptive fields cleanly can still drop every link. The result validates and is useless, because a record with no link is a description of something nobody can reach. Test for it explicitly.

Can I filter on any property?

Only on the ones the service declares as queryable, exactly as with OGC API - Features. Fetch the collection’s queryables document first. And check the conformance declaration before sending any filter at all, because a service without the filter class ignores the parameter and returns the entire catalogue rather than an error.

Do record identifiers have to change?

No, and they must not. Existing citations, harvest states and downstream references all use the CSW identifier. Carry it through as the Feature id — if it is an ugly UUID or a file identifier with a namespace prefix, that is a small aesthetic cost against breaking every reference that already exists.


Back to Spatial Metadata & Catalog Integration

Related