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
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.
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.