Streaming — Constant-Memory Parsing for Big XML¶
Everything covered so far (Objectify — Dotted Navigation, Dictify — XML to Dict, XPath) is built
on pugixml’s in-memory DOM — fast, but the whole document has to fit in
RAM. pygixml’s streaming layer is a second, independent engine: a
self-contained, inlined yxml push parser
that reads an XML file (or string, or file-like object) one chunk at a
time and lets you process matching elements without ever holding the
full document in memory. This is the layer to reach for once a file is
too big — or you simply don’t want — to load whole.
import pygixml
for record in pygixml.iterfind("big.xml", "record"):
print(record.tag, record.get("id"), record.find("name").text)
record.clear() # drop this element's memory before the next one
Three layers build on top of each other, from lowest- to highest-level:
Function |
What it gives you |
|---|---|
|
|
Just the matched |
|
Each match already converted to a |
And for the common “convert the whole file” case, pygixml.jsonify
adds two endpoints that skip Python objects entirely and write straight
to disk — see Jsonify — XML to JSON.
iterparse / iterfind¶
- pygixml.iterparse(source, events=('end',), tag=None, stack_size=4096, chunk_size=65536)
An incremental,
ElementTree-style parser. Readssourceinchunk_size-byte chunks and yields(event, elem)tuples as elements start and/or end, without ever building a full document tree.- Parameters:
source (str or os.PathLike or bytes or bytearray or file-like) – A path,
bytes/bytearrayof XML content, or any file-like object with.read().events (tuple[str, ...]) – Which events to yield:
"start","end", or both. Only"end"events carry a fully-populated element (children, text, attributes); a"start"event’s element has its tag and attributes but no children or text yet.tag (str or None) – If given, only elements with this tag name produce events — everything else is skipped without allocating a
StreamElementfor it.stack_size (int) – Size (bytes) of yxml’s internal element/attribute name stack. Increase this only if you hit a “stack too small” parse error on documents with unusually deep nesting or very long tag/attribute names.
chunk_size (int) – Bytes read per I/O operation from
source.
- Returns:
A generator of
(event, elem)tuples.- Return type:
Iterator[tuple[str, pygixml.StreamElement]]
- Raises:
PygiXMLError – On malformed XML.
import pygixml for event, elem in pygixml.iterparse("big.xml", events=("start", "end")): if event == "start" and elem.tag == "record": print("entering record", elem.get("id")) elif event == "end" and elem.tag == "record": handle(elem) elem.clear()
- pygixml.iterfind(source, tag, stack_size=4096, chunk_size=65536)
Shortcut for
iterparse(source, events=("end",), tag=tag)that yieldsStreamElementobjects directly — no(event, elem)tuple to unpack.- Parameters:
source – Same as
iterparse().tag (str) – Tag name of the elements to yield. Matches at any depth, including nested occurrences of the same tag.
- Returns:
A generator of matched elements.
- Return type:
Iterator[pygixml.StreamElement]
for record in pygixml.iterfind("big.xml", "record"): handle(record) record.clear()
StreamElement¶
- class pygixml.StreamElement
A small,
ElementTree-like element produced while streaming. It is not connected to a pugixml document — it’s a standalone tree of plain Python objects (built once, for this one match, then thrown away), with atag, anattribdict, optionaltext/tailstrings, and childStreamElementnodes.- tag: str
- attrib: dict
- text: str or None
- tail: str or None
- children: list[pygixml.StreamElement]
Direct children. Also available via iteration (
for child in elem), indexing (elem[0]), andlen(elem).
- get(key, default=None)
attrib.get(key, default).
- find(path)
First descendant matching
path, orNone.pathsupports"tag","a/b/c"(direct-child traversal),"*"(any child), and".//tag"(any descendant).
- findall(path)
All descendants matching
path(same syntax asfind()). Always returns a list, possibly empty.
- findtext(path, default=None)
.textof the first match ofpath, or default.
- iter(tag=None)
Depth-first iterator over this element and all its descendants, optionally restricted to
tag.
- clear()
Drop this element’s attributes, text, tail, and children, freeing the memory they hold. Call this after processing each element yielded by
iterfind()— it’s what keeps peak memory flat across millions of elements.
- to_dict(attr_prefix='@', cdata_key='#text', force_list=None)
Convert this element (and its subtree) to a plain
dict, using the exact same conventions aspygixml.dictify.parse()(@-prefixed attributes,#textfor mixed content, repeated siblings collapsed into a list).
- to_json(attr_prefix='@', cdata_key='#text', force_list=None)
Same conversion as
to_dict(), returned as a JSONstrinstead of adict.
for record in pygixml.iterfind("big.xml", "record"): d = record.to_dict() # {'@id': '1', 'name': 'Ali', ...} line = record.to_json() # '{"@id": "1", "name": "Ali", ...}' record.clear()
Streaming straight to dict or JSON¶
Wrapping every loop in elem.to_dict() / elem.to_json() /
elem.clear() is common enough to have its own generators:
- pygixml.dictify.iterdict(source, tag, attr_prefix='@', cdata_key='#text', force_list=None, stack_size=4096, chunk_size=65536)
Generator yielding
to_dict()for every element matchingtag, clearing each one automatically once converted. Identical to looping overiterfind()yourself, just shorter.from pygixml import dictify for record in dictify.iterdict("big.xml", "record"): print(record["@id"], record["name"]) # plain dict, no XML API
- pygixml.jsonify.iterjsonl(source, tag, attr_prefix='@', cdata_key='#text', force_list=None, stack_size=4096, chunk_size=65536)
Generator yielding
to_json()(one JSON object string per match) for every element matchingtag. Each yielded line is independently parseable JSON — write them to a.jsonlfile yourself, forward them over a socket, push them onto a queue, whatever fits:from pygixml import jsonify with open("big.jsonl", "w") as f: for line in jsonify.iterjsonl("big.xml", "record"): f.write(line + "\n")
If the destination really is just a
.jsonlfile and you don’t need the records in Python at all,pygixml.jsonify.stream_jsonl()does the same job without creating a single Python object per element — see Jsonify — XML to JSON.
Sources accepted everywhere¶
iterparse(), iterfind(),
iterdict(), and iterjsonl()
all accept the same set of source types:
Type |
Behavior |
|---|---|
|
Treated as a filesystem path and opened for reading. |
|
Treated as XML content (not a path) and read from memory. |
File-like object |
Anything with a |
Note
A plain str is always treated as a path, never as XML content
— pass bytes (e.g. xml.encode()) if you have an XML string in
memory and want to stream it without writing it to a file first.
Memory model¶
Peak memory while streaming is bounded by the size of one matched
element’s own subtree, not the document. A 10 GB XML file with
millions of small, flat <record> elements streams in roughly the
same peak memory as a 10 KB one — only the time scales with the file
size, not the memory.
import pygixml
n, total_score = 0, 0
for record in pygixml.iterfind("huge_export.xml", "record"):
n += 1
total_score += int(record.findtext("score", "0"))
record.clear()
print(f"{n} records, average score {total_score / n:.1f}")
# peak memory: roughly constant, regardless of how big huge_export.xml is