Mapping ISO 19115 to Dublin Core for CSW

A crosswalk and transform for exposing rich ISO 19115/19139 source records as the flat Dublin Core csw:Record that a CSW GetRecords response must return.

This page is the hands-on companion to CSW Catalog Schema Mapping & Validation and sits within the broader Metadata Catalog Automation & Ingestion Workflows practice; read the parent guide first for the pipeline-level view of how transformation and validation are decoupled stages. The problem addressed here is concrete: a catalog stores detailed ISO 19115 metadata, but the CSW csw:GetRecords operation in its default output schema hands clients a lossy, flat Dublin Core view. Without a deliberate, tested crosswalk, titles arrive empty, bounding boxes vanish, and multi-valued keywords collapse into one string, breaking every downstream client that queries the catalog by the OGC contract.

Prerequisites

Confirm the following before wiring a crosswalk into the catalog. Each corresponds to a common cause of an empty or malformed csw:Record.

  • A running pycsw 2.6+ catalog (or another CSW 2.0.2 / 3.0 server) whose repository holds ISO 19139-encoded source records.
  • Access to edit the catalog’s profile/output mapping and, if using XSLT, a processor such as xsltproc or saxon for standalone testing.
  • The ISO 19139 namespaces (gmd, gco, gml, srv) and the Dublin Core / CSW namespaces (dc, dct, ows, csw) to hand — namespace prefix errors are the most frequent cause of a silent empty element.
  • Source records that already cleared the ingestion gate in Validating ISO 19115 Metadata Before Ingestion, so the ISO input you transform is structurally sound.
  • Awareness of the OAI-PMH harvest path in Automated Metadata Ingestion via OAI-PMH, since harvested oai_dc records and CSW Dublin Core share the same element set and often the same mapping bugs.

CSW defines Dublin Core as its mandatory common return schema: any conformant client can request outputSchema=http://www.opengis.net/cat/csw/2.0.2 and expects a csw:Record of Dublin Core elements. ISO 19139 remains the richer output schema for capable clients, but the Dublin Core view is the interoperability floor, and a lossy crosswalk breaks the floor for everyone.

Step-by-step implementation

1. Understand why GetRecords returns Dublin Core

A CSW server advertises one or more outputSchema values. Simple and federated clients request the Dublin Core schema because it is the only one every CSW is required to support, so the server must project each stored ISO 19139 record down to a flat csw:Record. That projection is lossy by design: ISO’s nested MD_DataIdentification, EX_GeographicBoundingBox, and repeated MD_Keywords must be flattened into a handful of unqualified Dublin Core elements plus an ows:BoundingBox. Getting the projection right — not the storage — is what makes the catalog interoperable.

ISO 19115 to Dublin Core crosswalk A two-column mapping diagram. On the left are nested ISO 19139 elements: citation title, abstract, file identifier, keyword, geographic bounding box, and citation date. Arrows cross to the right column of Dublin Core and OWS targets: dc:title, dct:abstract, dc:identifier, dc:subject, ows:BoundingBox, and dc:date, all collected into a flat csw:Record. The keyword and bounding box arrows are highlighted to show many-to-one flattening. ISO 19115 / 19139 Dublin Core · csw:Record gmd:citation//gmd:title gco:CharacterString gmd:abstract gmd:fileIdentifier gmd:keyword (repeated) many values EX_GeographicBoundingBox 4 corner ordinates gmd:citation//gmd:date dc:title dct:abstract dc:identifier dc:subject (repeated) one per keyword ows:BoundingBox dc:date

2. Build the element crosswalk

Fix the mapping before writing any transform. The table below is the contract every implementation must honor; the multiplicity column is where lossy shortcuts creep in. Note that a bounding box is not a single value but four ordinates projected into one ows:BoundingBox with ows:LowerCorner and ows:UpperCorner, and that ISO’s date-with-role must collapse to a single dc:date.

ISO 19115 / 19139 source Dublin Core / OWS target Multiplicity Notes
gmd:identificationInfo//gmd:citation//gmd:title/gco:CharacterString dc:title 1 First title only if repeated
gmd:identificationInfo//gmd:abstract/gco:CharacterString dct:abstract 1 Also duplicate to dc:description for older clients
gmd:fileIdentifier/gco:CharacterString dc:identifier 1 Must be the queryable record id
gmd:identificationInfo//gmd:descriptiveKeywords//gmd:keyword dc:subject many One dc:subject per keyword — never join
gmd:EX_GeographicBoundingBox (4 ordinates) ows:BoundingBox 1 Lower = west/south, Upper = east/north; CRS urn:ogc:def:crs:EPSG::4326
gmd:citation//gmd:date/gmd:CI_Date//gmd:date dc:date 1 Prefer publication, else revision, else creation
gmd:hierarchyLevel/gmd:MD_ScopeCode/@codeListValue dc:type 1 e.g. dataset, service
gmd:distributionInfo//gmd:MD_Format/gmd:name dc:format many Distribution format labels
gmd:identificationInfo//gmd:resourceConstraints//gmd:useLimitation dct:rights many License / use constraints

3. Implement the transform in XSLT

An XSLT stylesheet is the portable way to express the crosswalk and the one most catalogs (pycsw included) accept as a pluggable output mapping. Declare both namespace families, emit each element per the table, and iterate keywords with xsl:for-each so multiplicity is preserved.

<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0"
  xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
  xmlns:gmd="http://www.isotc211.org/2005/gmd"
  xmlns:gco="http://www.isotc211.org/2005/gco"
  xmlns:csw="http://www.opengis.net/cat/csw/2.0.2"
  xmlns:dc="http://purl.org/dc/elements/1.1/"
  xmlns:dct="http://purl.org/dc/terms/"
  xmlns:ows="http://www.opengis.net/ows">

  <xsl:template match="gmd:MD_Metadata">
    <csw:Record>
      <dc:identifier>
        <xsl:value-of select="gmd:fileIdentifier/gco:CharacterString"/>
      </dc:identifier>
      <dc:title>
        <xsl:value-of select="(.//gmd:citation//gmd:title/gco:CharacterString)[1]"/>
      </dc:title>
      <dc:type>
        <xsl:value-of select="gmd:hierarchyLevel/gmd:MD_ScopeCode/@codeListValue"/>
      </dc:type>
      <!-- one dc:subject per keyword: multiplicity preserved -->
      <xsl:for-each select=".//gmd:descriptiveKeywords//gmd:keyword/gco:CharacterString">
        <dc:subject><xsl:value-of select="."/></dc:subject>
      </xsl:for-each>
      <dct:abstract>
        <xsl:value-of select=".//gmd:abstract/gco:CharacterString"/>
      </dct:abstract>
      <!-- publication date preferred, else first available date -->
      <dc:date>
        <xsl:value-of select="(.//gmd:CI_Date[.//gmd:CI_DateTypeCode/@codeListValue='publication']//gmd:date/gco:Date
                              | .//gmd:CI_Date//gmd:date/gco:Date)[1]"/>
      </dc:date>
      <!-- bounding box: four ordinates into LowerCorner / UpperCorner -->
      <xsl:for-each select="(.//gmd:EX_GeographicBoundingBox)[1]">
        <ows:BoundingBox crs="urn:ogc:def:crs:EPSG::4326">
          <ows:LowerCorner>
            <xsl:value-of select="concat(gmd:westBoundLongitude/gco:Decimal,' ',gmd:southBoundLatitude/gco:Decimal)"/>
          </ows:LowerCorner>
          <ows:UpperCorner>
            <xsl:value-of select="concat(gmd:eastBoundLongitude/gco:Decimal,' ',gmd:northBoundLatitude/gco:Decimal)"/>
          </ows:UpperCorner>
        </ows:BoundingBox>
      </xsl:for-each>
    </csw:Record>
  </xsl:template>
</xsl:stylesheet>

4. Wire the mapping into the catalog

In pycsw the record-to-Dublin-Core projection is driven by the repository mapping and the queryables config, not usually by a raw stylesheet, so express the same crosswalk as configuration. Point each core queryable at the database column that holds the extracted ISO value; pycsw populates those columns from the ISO source at transaction time. Keep the config in version control alongside the stylesheet used for validation.

# pycsw.yml — repository mapping expressing the same crosswalk (valid YAML)
server:
  home: /var/lib/pycsw
  url: https://catalog.example.org/csw
  encoding: UTF-8

repository:
  source: postgresql://pycsw@db:5432/catalog
  table: records
  mappings:
    pycsw:Identifier: identifier      # -> dc:identifier
    pycsw:Title: title                # -> dc:title
    pycsw:Abstract: abstract          # -> dct:abstract
    pycsw:Keywords: keywords          # -> dc:subject (comma-split at output)
    pycsw:Type: type                  # -> dc:type
    pycsw:Modified: date_modified     # -> dc:date
    pycsw:BoundingBox: wkt_geometry   # -> ows:BoundingBox (EPSG:4326)
    pycsw:Format: format              # -> dc:format
    pycsw:AccessConstraints: accessconstraints  # -> dct:rights

5. Handle multiplicity and missing elements

Two failure modes dominate: repeated ISO elements silently collapsed to one, and required Dublin Core elements emitted empty when the ISO source omits them. Keep dc:subject, dc:format, and dct:rights as genuine repeats; guard scalar targets so a missing source produces no element rather than an empty one; and pick a deterministic date when several CI_Date roles exist. The tradeoff is explicit in config below.

; crosswalk-policy.ini — projection rules consumed by the output mapper
[multiplicity]
dc.subject   = repeat        ; one element per keyword, never join
dc.format    = repeat
dct.rights   = repeat
dc.title     = first         ; take first title if the citation repeats it

[missing]
emit_empty_elements = false  ; omit the element rather than emit an empty one
required           = dc.identifier, dc.title

[date]
role_priority = publication, revision, creation
normalize_utc = true

Round-tripping is not a goal — knowing what you lost is

It is tempting to judge a crosswalk by whether the mapped record can be converted back into the original. For a mapping from a rich standard to a simpler one, it cannot be, and pursuing that goal produces the worst kind of crosswalk: one that smuggles structure into free-text fields so it can be recovered later, at the cost of records that are unreadable to every client that does not know the encoding.

Two ways to handle content the target vocabulary cannot hold Side-by-side comparison of smuggling structure into free text versus dropping it and recording the loss, with the consequence of each for standard clients and for auditability. Encode it into free text structure survives, in a form only your own tooling understands standard clients show the encoding to users as if it were prose interoperability was the point of mapping Drop it, and record the drop the mapped record stays clean and valid for every client a mapping report names each element dropped, per record and per source recovery path: the retained original

Emit the mapping report as data rather than as log lines, keyed by record and element, and it becomes useful twice over: a curator can see which sources routinely carry content the crosswalk discards, and a future extension to the mapping has a ready-made list of what it would gain. Both of those are conversations worth having with the upstream, and neither is possible if the loss was silent.

Verification

Test the transform standalone before trusting the live endpoint, then confirm the served csw:Record over the wire.

# 1. Apply the stylesheet to a known ISO record and inspect the projected csw:Record
xsltproc iso-to-dc.xsl sample-iso19139.xml | xmllint --format -
# -> a <csw:Record> with dc:title, dc:identifier, one dc:subject per keyword, ows:BoundingBox

# 2. Count keywords in source vs dc:subject in output — must match (multiplicity preserved)
echo "source: $(xmllint --xpath 'count(//*[local-name()=\"keyword\"])' sample-iso19139.xml)"
echo "output: $(xsltproc iso-to-dc.xsl sample-iso19139.xml | xmllint --xpath 'count(//*[local-name()=\"subject\"])' -)"

# 3. Ask the live catalog for Dublin Core output and confirm the bounding box survived
curl -sS "https://catalog.example.org/csw?service=CSW&version=2.0.2&request=GetRecords\
&typenames=csw:Record&elementsetname=full\
&outputschema=http://www.opengis.net/cat/csw/2.0.2&resulttype=results" \
  | xmllint --xpath '//*[local-name()="BoundingBox"]' -

Matching keyword counts, a non-empty dc:title and dc:identifier, and a populated ows:BoundingBox with EPSG:4326 corners together confirm the crosswalk is faithful and not silently lossy.

Dates are the field that breaks quietly

Of all the elements a crosswalk touches, dates cause the most downstream trouble, because they look simple and are not. A rich record distinguishes creation, publication and revision; the simpler vocabulary usually has one date element, sometimes qualified. Choosing which one to map is a decision about what “the date of this dataset” means to a searcher, and different sources will have made that decision differently before you ever see their records.

Three dates, one target element Creation, publication and revision dates compared by what each means to a searcher and the consequence of mapping it as the single date. creation when the data was made as the single date, a long-lived dataset looks permanently old buries maintained data publication when it was released usually the closest match to what a searcher means the sensible default revision when it last changed a metadata typo fixed today makes old data look new inflates recency rankings Whichever is chosen, apply it consistently across sources and record the choice — a mixed rule makes date sorting meaningless

Partial dates are the second half of this problem: a record carrying only a year is common and entirely legitimate, and a crosswalk that pads it to the first of January produces a false precision that sorting and filtering will treat as real. Preserve the granularity that was given where the target allows it, and where it does not, record the padding in the mapping report so nobody later mistakes an artefact for a fact.

Troubleshooting matrix

Symptom Likely cause Fix
dc:title empty in the response Wrong XPath into the nested citation, or namespace prefix mismatch Select .//gmd:citation//gmd:title/gco:CharacterString; declare gmd/gco correctly
All keywords collapse into one dc:subject Values joined instead of iterated Emit one dc:subject per keyword with xsl:for-each; set dc.subject = repeat
ows:BoundingBox missing or corners swapped Ordinates not mapped, or lower/upper reversed Lower = west+south, Upper = east+north; iterate the first EX_GeographicBoundingBox
Client rejects the record as invalid Output namespaces wrong (dc vs dct, ows version) Match the CSW 2.0.2 profile namespaces exactly for every element
Records return empty elements for absent fields Mapper emits a node even when source is missing Set emit_empty_elements = false; guard scalar outputs before emitting
Wrong or nondeterministic dc:date Multiple CI_Date roles, no priority chosen Apply role_priority = publication, revision, creation; take the first match
GetRecords ignores the crosswalk Client requested the ISO output schema, not Dublin Core Confirm outputSchema is the CSW 2.0.2 Dublin Core URI in the request
BoundingBox present but unqueryable spatially CRS attribute omitted or non-4326 Set crs="urn:ogc:def:crs:EPSG::4326" and store geometry in that CRS

For the authoritative element set and encoding rules, see the OGC Catalogue Service (CSW) standard and the Dublin Core Metadata Terms.

Up one level: CSW Catalog Schema Mapping & Validation.