How to Map 9XX MARC Fields to BIBFRAME 2.0
This page answers one narrow mapping question: how do you route institution-defined 9XX MARC fields into a BIBFRAME 2.0 graph when the ontology has no native class for local tags, without silently collapsing repeated holdings or orphaning item nodes? It sits under MARC21 Field Mapping for Modern Pipelines, the parse-to-graph stage this transform belongs to, and within the broader Core Architecture & Catalog Standards architecture. If your converter handles clean 1XX–8XX bibliographic data but drops, duplicates, or fragments local holdings and item tags — 949, 952, 955, 990, 999 — the symptoms and fixes below apply directly to the transform stage that emits RDF triples.
Problem Framing
The failure is quiet: the converter runs to completion, exits zero, and writes a graph that is missing data. A bibliographic record that carried three repeated 952 holdings emerges as a single bf:Item, and some bf:Item nodes have no edge back to their parent bf:Instance at all. Because there is no crash, the loss only surfaces later, when a discovery layer shows one copy where the ILS holds three.
You catch it by counting. Run a SPARQL query against the staging graph that compares emitted items to expected holdings, and the mismatch is immediate:
$ arq --data staging.ttl --query holdings_count.rq
-------------------------
| instance | items |
=========================
| inst:b1042 | 1 | # ILS record 001=b1042 has THREE 952 fields
-------------------------
$ arq --data staging.ttl --query orphan_items.rq
-------------------------
| orphan_item |
=========================
| urn:bf:item:9f3c1a... | # bf:Item with no incoming bf:hasItem edge
-------------------------
The transform worker’s structured log confirms the two distinct signatures — a collapse and a fragment — on the same record:
{"lvl":"WARN","evt":"subfield_collision","record_id":"b1042","tag":"952","occurrences":3,"emitted":1}
{"lvl":"ERR","evt":"graph_fragmentation","record_id":"b1042","tag":"952","item":"urn:bf:item:9f3c1a","missing_edge":"bf:hasItem"}
Root Cause
There are three independent causes, and a broken converter usually has more than one.
No native local-field class. BIBFRAME 2.0 deliberately does not expose a bf:LocalField class, because local semantics are institution-specific. A generic MARC-to-BIBFRAME mapper that only knows the standardized 1XX–8XX ranges therefore has no rule for 9XX and either drops the fields entirely or dumps every subfield into one flat bf:note. The mapping is not wrong in the ontology sense; it simply was never authored, so the local data evaporates. The reverse direction — reconstituting these tags from a graph — is handled by the BIBFRAME to MARC21 Conversion Workflows layer, and any routing policy you define here must round-trip through it.
Flat-dict overwrite of repeated tags. The collapse in the diagnostic above comes from indexing fields by tag into a plain dictionary — fields = {f.tag: f for f in record} — which keeps only the last occurrence of each tag. Repeated 952 holdings, which are the norm for any title held in multiple branches, silently reduce to one. MARC data fields are inherently repeatable; any structure keyed on tag alone destroys that cardinality before a single triple is written.
Wildcard get_fields misuse. The orphan/fragment signature often traces to a mapper that calls record.get_fields("9XX") expecting a pattern match. pymarc.Record.get_fields() does not accept wildcards — it matches exact tag strings — so the call returns an empty list, the local block is skipped, and any bf:Item URI minted elsewhere in the pipeline (e.g. from an 001-derived stub) is left with no properties and no parent edge.
The unifying principle: every 9XX field needs an explicit routing decision — which BIBFRAME class it becomes, which properties its subfields fill, and a stable identity per occurrence — because nothing in the ontology or the parser will supply one for you.
Solution
Author a deterministic routing table, enumerate the exact local tags your institution uses, iterate every occurrence, and mint a stable per-occurrence URI. The full parse-to-graph context lives in MARC21 Field Mapping for Modern Pipelines; the change here is confined to how the local block is routed and identified.
First, decide the target class and property alignment per tag. Administrative/processing tags resolve to bf:AdminMetadata; holdings and copy tags resolve to bf:Item linked to the bf:Instance via bf:hasItem. A minimal, auditable policy:
| Tag | Indicator 1 | Subfield | BIBFRAME target | Property |
|---|---|---|---|---|
| 952 | copy status | $a |
bf:Item |
bf:shelfMark |
| 952 | copy status | $b |
bf:Item |
bf:enumerationAndChronology |
| 952 | copy status | $c |
bf:Item |
bf:note |
| 955 | processing | $a |
bf:AdminMetadata |
bf:processingDate |
| 990 | any | $a |
bf:AdminMetadata |
bf:generationProcess |
| 999 | copy status | $a |
bf:Item |
bf:shelfMark |
Before — a flat dict keyed on tag; repeated holdings are overwritten, wildcards match nothing:
def map_local_fields(record) -> dict:
# BUG 1: keyed on tag, so the last 952 wins and the others vanish.
# BUG 2: "9XX" is not a pattern; get_fields returns [] for it.
fields = {f.tag: f for f in record.get_fields("9XX")}
return {tag: f.get_subfields("a") for tag, f in fields.items()}
After — enumerate exact tags, iterate every occurrence, and derive a stable bf:Item URI so repeated fields become distinct, linked nodes:
import hashlib
import logging
from typing import Iterator
from pymarc import Record
from rdflib import Graph, Namespace, URIRef, Literal
logger = logging.getLogger("marc_bibframe.local_fields")
BF = Namespace("http://id.loc.gov/ontologies/bibframe/")
# pymarc.Record.get_fields() matches exact tags only — enumerate every local
# tag your institution actually emits; there is no "9XX" wildcard.
LOCAL_ITEM_TAGS = ("949", "952", "999")
LOCAL_ADMIN_TAGS = ("955", "990")
# Subfield -> BIBFRAME property, per the routing table above.
_ITEM_SUBFIELD_MAP = {
"a": BF.shelfMark,
"b": BF.enumerationAndChronology,
"c": BF.note,
}
def _item_uri(record_id: str, tag: str, occurrence_idx: int) -> URIRef:
"""Stable, collision-free URI for one occurrence of a repeatable field.
Hashing (record_id, tag, occurrence) means the same physical copy always
maps to the same node across re-runs, so re-ingesting a record is
idempotent instead of minting fresh orphans each pass.
"""
payload = f"{record_id}:{tag}:{occurrence_idx}".encode("utf-8")
return URIRef(f"urn:bf:item:{hashlib.sha256(payload).hexdigest()[:16]}")
def map_local_to_bibframe(record: Record, instance: URIRef) -> Graph:
"""Route a record's 9XX block into a BIBFRAME graph, preserving cardinality.
Every occurrence of a repeatable tag becomes a distinct bf:Item linked to
its parent bf:Instance via bf:hasItem, so N holdings yield exactly N items.
"""
g = Graph()
ctrl = record.get("001")
record_id = ctrl.data if ctrl is not None else "unknown"
for tag in LOCAL_ITEM_TAGS:
for idx, field in enumerate(record.get_fields(tag)):
item = _item_uri(record_id, tag, idx)
g.add((instance, BF.hasItem, item)) # never orphan the item
for code, prop in _ITEM_SUBFIELD_MAP.items():
for value in field.get_subfields(code):
value = value.strip()
if not value: # skip empties -> no rdf:nil pollution
continue
g.add((item, prop, Literal(value)))
for tag in LOCAL_ADMIN_TAGS:
for idx, field in enumerate(record.get_fields(tag)):
for value in field.get_subfields("a"):
if value.strip():
g.add((instance, BF.adminMetadata, Literal(value.strip())))
logger.info("mapped local fields", extra={
"record_id": record_id,
"item_tags": {t: len(record.get_fields(t)) for t in LOCAL_ITEM_TAGS},
})
return g
Two rules make this deterministic rather than merely working. When several local tags could describe the same copy, process them in a fixed tag-priority order (952 before 955, 990 before 999) so the emitted triples are reproducible run to run. And when a legacy subfield carries pipe-delimited composite codes ($a "STACK|MAIN|REF"), split it into discrete Literals before serialization rather than injecting one monolithic string — a small sanitization step that keeps the graph queryable.
Compliance or Privacy Impact
Local 9XX fields are the single most common place patron data leaks into a public catalog graph. Unlike standardized bibliographic fields, institution-defined tags routinely carry item barcodes (often in 949/999 $i), local hold or routing notes that name a patron, and circulation annotations. Mapping the local block verbatim into a BIBFRAME graph that will be published or federated therefore expands PII surface area even though the bibliographic mapping looks clean.
Two controls must run before any 9XX subfield reaches the graph. First, apply patron PII masking to the local block: drop barcode-bearing subfields entirely, and hash or redact free-text notes rather than emitting them as bf:note. Because masking is deterministic, running it inside the per-occurrence loop above yields the same output every pass and cannot be bypassed by a later shortcut. Second, honour the class-specific retention rules in data privacy boundaries in library systems — an item’s shelf mark may persist indefinitely, but a routing note tied to a named patron may not. Emit one audit event per mapped field (record id, tag, occurrence, and whether a subfield was masked or dropped) so the trail stays complete; retaining nothing across records must not cost you audit completeness.
Verification
Confirm the fix three ways: cardinality is preserved, no item is orphaned, and the mapping is idempotent across re-runs.
Cardinality preserved. Assert that N repeated tags produce exactly N linked items:
def test_repeated_952_yield_distinct_items():
record = _fixture_with_repeated_field("952", count=3, record_id="b1042")
instance = URIRef("http://example.org/inst/b1042")
g = map_local_to_bibframe(record, instance)
items = set(g.objects(instance, BF.hasItem))
assert len(items) == 3, "each 952 occurrence must be a distinct bf:Item"
No orphaned items. Run a SPARQL ASK that must return false — every bf:Item has an incoming bf:hasItem:
from rdflib import Graph
_ORPHAN_ASK = """
ASK {
?item a <http://id.loc.gov/ontologies/bibframe/Item> .
FILTER NOT EXISTS {
?inst <http://id.loc.gov/ontologies/bibframe/hasItem> ?item .
}
}
"""
def assert_no_orphan_items(graph: Graph) -> None:
assert graph.query(_ORPHAN_ASK).askAnswer is False, "orphaned bf:Item found"
Idempotent re-ingest. Map the same record twice and confirm the two graphs are isomorphic — stable URIs mean a re-run adds no new nodes. Leader-level validation of each record before it reaches this transform is covered in validating MARC leader fields before database insert; records that fail routing should divert to the schema validation quarantine queue rather than abort the batch.
Related
- MARC21 Field Mapping for Modern Pipelines — the full parse-to-graph stage this local-field transform belongs to
- Handling UTF-8 Encoding in Legacy MARC Records — decode the bytes correctly before you route the 9XX block
- BIBFRAME to MARC21 Conversion Workflows — round-trip local fields back out of the graph
- PII Masking in Patron Data Exports — strip barcodes and patron notes before serialization