Optimizing pymarc Performance for Large Record Sets
This page answers one narrow operational question: why does a pymarc-based ingest worker climb steadily in memory and get OOM-killed partway through a multi-gigabyte MARC21 batch, and how do you make it run at flat resident set size instead? It sits under Parsing MARC Records with pymarc, which covers the parse stage end to end, and within the broader Catalog Ingestion & ILS Sync Pipelines architecture. If you are ingesting nightly vendor feeds, legacy union-catalog exports, or high-frequency circulation deltas, the symptoms and fixes below apply directly to the worker that decodes ISO 2709 bytes into Record objects.
Problem Framing
The failure shows up as a container that dies mid-batch with no application-level traceback. The worker’s resident set size grows monotonically with the number of records processed, then the kernel OOM killer reaps it. In practice you see the process vanish and a kernel log line like this:
$ dmesg -T | grep -i oom
[Wed Jul 1 02:41:17 2026] Out of memory: Killed process 4812 (python) total-vm:9438208kB, anon-rss:8912344kB, file-rss:0kB
[Wed Jul 1 02:41:17 2026] oom_reaper: reaped process 4812 (python), now anon-rss:0kB
A quick tracemalloc snapshot taken at two points in the batch confirms that allocation scales with records seen rather than staying constant:
[ Top allocations at record 50,000 ]
pymarc/record.py:212 size=612 MiB count=1,048,102
[ Top allocations at record 100,000 ]
pymarc/record.py:212 size=1224 MiB count=2,096,204
The size roughly doubles as the record count doubles. That linear growth is the signature of retaining every parsed record instead of releasing it after emit. The same pattern appears with MARCXML sources, except the memory is consumed by the XML DOM rather than by Record objects.
Root Cause
There are three distinct causes, and a given worker usually has one of them.
Unbounded list accumulation. The most common cause is collecting parsed records into a Python list — often hidden inside an innocent-looking comprehension such as records = [r for r in MARCReader(fh)] or a records.append(record) inside the read loop. Each Record retains its full field graph (leader, directory, control fields, and every data field with its subfields), so a multi-gigabyte source expands to several times its on-disk size once resident. Nothing is freed until the list goes out of scope, which is after the whole file is read — exactly when the heap is already exhausted.
MARCXML DOM materialization. For MARCXML feeds, pymarc.parse_xml_to_array builds the entire document tree in memory before returning a list of records. A 4 GB MARCXML export becomes a DOM many times that size. The function is convenient for small files and catastrophic for large ones.
Per-record decoder instantiation. A subtler cost, visible as CPU rather than memory, is constructing a MARC-8 to Unicode converter (or compiling a regex) inside the read loop. On MARC-8-heavy feeds this dominates parse time. It does not cause the OOM, but it is why an otherwise-fixed streaming worker is still slow. MARC-8 versus UTF-8 handling itself is covered in handling UTF-8 encoding in legacy MARC records; here the concern is only where the converter is built.
The unifying principle: pymarc.MARCReader is already an incremental parser. It reads one record at a time from the file object. Any memory growth is something your code adds on top of it by holding references.
Solution
Consume the reader as a generator and emit each record downstream immediately, retaining nothing across iterations. The parse stage in Parsing MARC Records with pymarc shows the full extraction logic; the change here is purely about lifetime.
Before — accumulates every record, grows without bound:
from pymarc import MARCReader
def load_records(filepath: str) -> list:
with open(filepath, "rb") as fh:
# Every Record is retained until the list is returned — RSS grows
# linearly with file size and OOM-kills the worker on large batches.
return [record for record in MARCReader(fh) if record is not None]
After — streams at flat RSS, one record resident at a time:
import logging
from typing import Iterator
from pymarc import MARCReader, Record
logger = logging.getLogger("marc_ingest.stream")
def stream_marc_records(filepath: str) -> Iterator[Record]:
"""Yield MARC records one at a time from an ISO 2709 file.
MARCReader is incremental, so we hand it the open binary file object and
never build a list. Each record is garbage-collected once the caller has
emitted it downstream, keeping resident set size flat regardless of the
input file's size. A None value marks an unrecoverable parse error for the
current record; the reader resynchronizes on the next record terminator.
"""
with open(filepath, "rb") as fh:
reader = MARCReader(fh, to_unicode=True, force_utf8=False,
utf8_handling="replace")
for record in reader:
if record is None:
logger.warning(
"unrecoverable parse error; record skipped",
extra={"reader_error": str(reader.current_exception)},
)
continue
yield record
The caller must not defeat the streaming by re-collecting: iterate and emit inside the loop (to a message broker, a bulk-insert batch of bounded size, or a checkpoint), then drop the reference.
For MARCXML, replace parse_xml_to_array with lxml.etree.iterparse, which surfaces one <record> element at a time. Crucially, call elem.clear() after each record and also delete already-processed siblings so lxml does not retain the growing prefix of the tree:
from typing import Iterator
from io import BytesIO
from lxml import etree
from pymarc import parse_xml_to_array, Record
_MARC_NS = "http://www.loc.gov/MARC21/slim"
def stream_marcxml(filepath: str) -> Iterator[Record]:
"""Constant-memory streaming of MARCXML via lxml iterparse.
parse_xml_to_array materializes the whole DOM; iterparse yields each
<record> as it closes. We clear the element and its consumed predecessors
so lxml releases the parsed prefix instead of holding the entire tree.
"""
context = etree.iterparse(filepath, events=("end",),
tag=f"{{{_MARC_NS}}}record")
for _event, elem in context:
xml_bytes = etree.tostring(elem, encoding="unicode").encode("utf-8")
records = parse_xml_to_array(BytesIO(xml_bytes))
if records and records[0] is not None:
yield records[0]
elem.clear()
while elem.getprevious() is not None:
del elem.getparent()[0]
Finally, hoist any per-record cost to module scope. pymarc performs MARC-8 to Unicode conversion internally when to_unicode=True, so the win here is compiling your own inspection regexes and caching tag-profile lookup tables once at import rather than rebuilding them per record — worth 40 to 60 percent CPU on MARC-8-heavy feeds:
import re
# Compiled once at import, reused for every record.
_LOCAL_TAG = re.compile(r"^(59\d|9\d\d)$")
_OVERSIZE_LIMIT = 10_240 # bytes; truncate before ORM hydration
To keep a long-running batch resumable without accumulating state, checkpoint a byte offset periodically rather than holding processed records. A lightweight SQLite offset tracker records the last committed position and batch sequence, so a killed worker restarts from the saved offset instead of the top of a multi-gigabyte file. Records that fail to parse still route to the schema validation quarantine queue rather than aborting the run, and downstream retry-and-backoff remains owned by async batch processing for catalog updates.
Compliance or Privacy Impact
Switching from list accumulation to streaming changes when records exist in memory, not what is in them, so it does not by itself expand or shrink PII surface area. But two compliance properties must be preserved through the change.
First, audit-log completeness. If a batch previously logged one summary record after building the full list, streaming removes that natural join point. Emit the audit event per record inside the loop instead — record length, field count, and a SHA-256 checksum of the raw byte payload — so the audit trail stays complete even though no record is retained. The retention rules governing how long each record class may persist live in data privacy boundaries in library systems.
Second, masking idempotency. The patron PII masking pass must run per record before the record crosses the parse boundary, exactly as it did in the batch version; streaming must not create a shortcut path that emits an unmasked record ahead of the mask. Because masking is deterministic and structure-preserving, running it inside the generator loop yields byte-identical output to the batch form — verify that equivalence explicitly (below) so the optimization cannot silently regress a privacy control.
Verification
Confirm the fix three ways: memory stays flat, the streamed output equals the batched output, and a killed worker resumes correctly.
Flat memory under load. Snapshot with tracemalloc at intervals and assert the peak does not scale with records processed:
import tracemalloc
def peak_mib_over_batch(filepath: str, sample_every: int = 25_000) -> float:
"""Return peak traced memory (MiB) while streaming a whole file."""
tracemalloc.start()
for i, _record in enumerate(stream_marc_records(filepath), start=1):
if i % sample_every == 0:
current, peak = tracemalloc.get_traced_memory()
# Peak must stay bounded; it should NOT track record count.
assert peak < 256 * 1024 * 1024, f"RSS regressed at record {i}"
_, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
return peak / (1024 * 1024)
For a live worker, tracemalloc usage and snapshot comparison are documented in the standard-library reference; the assertion above is the CI-friendly form.
Streamed output equals batched output. Guard the privacy and correctness invariant that streaming changed nothing but lifetime:
def test_stream_matches_batch(tmp_path):
fixture = _write_fixture(tmp_path, n=500) # small known-good .mrc
batched = [r.as_marc() for r in load_records(str(fixture))]
streamed = [r.as_marc() for r in stream_marc_records(str(fixture))]
assert streamed == batched # identical records, identical order
Resumability. Simulate a mid-batch kill by stopping at a checkpoint offset, then restart the iterator from the saved offset and confirm the control-number (001) range of the second pass begins exactly where the first stopped, with no gap or overlap against the vendor manifest. Full leader-level validation of each resumed record is covered in validating MARC leader fields before database insert.
Related
- Parsing MARC Records with pymarc — the full parse stage this optimization tunes
- Validating MARC Leader Fields Before Database Insert — leader checks to apply on resumed records
- Configuring Exponential Backoff for Sierra API Calls — throughput tuning on the live-read side of the pipeline
- Using Celery for Distributed Catalog Ingestion — sharding large batches across workers once each one streams cleanly