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
xsltprocorsaxonfor 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_dcrecords 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.
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.
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.
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.
Related
- CSW Catalog Schema Mapping & Validation — the parent practice: schema-aware transformation and the validation gate it plugs into.
- Validating ISO 19115 Metadata Before Ingestion — the check that guarantees the ISO input to this crosswalk is sound.
- Automated Metadata Ingestion via OAI-PMH — where
oai_dcrecords share the same Dublin Core element set and mapping pitfalls.
Up one level: CSW Catalog Schema Mapping & Validation.