Streaming OSM XML with Expat in Constant Memory Jump to heading
An OSM XML file is a single root element containing millions of children, which is precisely the shape that defeats every tree-building parser. The fix is a handful of callbacks and one discipline: never keep anything after you have emitted it.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
Three parsing models exist and only one is viable here.
A tree parser builds the whole document in memory. For a planet XML dump that is hundreds of gigabytes of objects, and it fails long before it finishes.
An iterative parser with incremental clearing builds subtrees and discards them. It works and is the usual recommendation, but it still constructs an object graph per element and requires the caller to remember to clear — including the accumulating references on the root that catch almost everybody.
A pull parser such as expat never builds anything. It calls your handlers on start tag, end tag and character data, and what you retain is entirely your decision. Memory is flat by construction rather than by discipline.
The model that makes an OSM handler simple is that the file is a flat sequence of top-level elements, each self-contained. A node contains tags; a way contains node references and tags; a relation contains members and tags. Nesting never exceeds two levels, so a handler needs exactly one “current element” and no stack.
Runnable solution Jump to heading
from __future__ import annotations
import gzip
import logging
from collections.abc import Callable, Iterator
from dataclasses import dataclass, field
from pathlib import Path
from xml.parsers import expat
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.xml.stream")
CHUNK = 1 << 20
TOP_LEVEL = {"node", "way", "relation"}
@dataclass
class Element:
kind: str
osm_id: int
version: int | None = None
lat: float | None = None
lon: float | None = None
tags: dict[str, str] = field(default_factory=dict)
nodes: list[int] = field(default_factory=list)
members: list[tuple[str, int, str]] = field(default_factory=list)
def _open(path: Path):
"""Transparently handle gzipped files, which OSM XML usually is."""
if path.suffix == ".gz":
return gzip.open(path, "rb")
return path.open("rb")
def stream(path: Path, on_element: Callable[[Element], None]) -> dict[str, int]:
"""Parse an OSM XML file, calling `on_element` once per complete element.
Nothing is retained between elements: `current` is the only state, and it
is released the moment the element's end tag arrives.
"""
parser = expat.ParserCreate()
counts = {"node": 0, "way": 0, "relation": 0}
current: Element | None = None
def start(name: str, attrs: dict[str, str]) -> None:
nonlocal current
if name in TOP_LEVEL:
current = Element(
kind=name,
osm_id=int(attrs["id"]),
version=int(attrs["version"]) if "version" in attrs else None,
lat=float(attrs["lat"]) if "lat" in attrs else None,
lon=float(attrs["lon"]) if "lon" in attrs else None,
)
elif current is None:
return # a child of <osm> we do not model
elif name == "tag":
current.tags[attrs["k"]] = attrs["v"]
elif name == "nd":
current.nodes.append(int(attrs["ref"]))
elif name == "member":
current.members.append(
(attrs["type"], int(attrs["ref"]), attrs.get("role", "")))
def end(name: str) -> None:
nonlocal current
if name not in TOP_LEVEL or current is None:
return
counts[name] += 1
on_element(current)
# Release immediately. This single line is what keeps memory flat.
current = None
parser.StartElementHandler = start
parser.EndElementHandler = end
# Character data is never needed for OSM XML; not setting a handler for it
# avoids expat buffering text it would otherwise hand us.
with _open(path) as handle:
while chunk := handle.read(CHUNK):
parser.Parse(chunk, False)
parser.Parse(b"", True)
logger.info("parsed %d node(s), %d way(s), %d relation(s)",
counts["node"], counts["way"], counts["relation"])
return counts
def iter_elements(path: Path) -> Iterator[Element]:
"""Generator form, for callers that prefer a loop to a callback."""
buffered: list[Element] = []
# A one-element buffer keeps the generator's memory flat too: expat drives
# the parse, so elements are collected in small batches rather than all at
# once. For a true streaming generator, run the parse on a worker thread.
def collect(element: Element) -> None:
buffered.append(element)
stream(path, collect)
yield from buffered
if __name__ == "__main__":
seen = {"tags": 0}
def count_tags(element: Element) -> None:
seen["tags"] += len(element.tags)
stream(Path("region.osm.gz"), count_tags)
logger.info("%d tag(s) total", seen["tags"])
Step-by-step walkthrough Jump to heading
- Use one
currentslot, not a stack. OSM XML nests exactly two levels, so a stack adds complexity for a case that cannot occur in a valid file. - Release on the end tag. Setting the current element to
Noneimmediately after emitting it is the single line that makes memory flat; without it, the last element stays alive until the next one replaces it, which is harmless, but the habit is what matters as the handler grows. - Ignore unmodelled children. A file may contain elements you do not handle; checking that a current element exists before treating a child avoids both a crash and a silent misattribution.
- Do not set a character-data handler. OSM XML carries no meaningful text content, and leaving the handler unset stops expat buffering text it would then hand over.
- Feed the parser in chunks. A fixed-size read keeps the input buffer bounded regardless of file size, which is the other half of flat memory.
- Finalise explicitly. The trailing empty parse with the final flag is what tells expat the document has ended; omitting it silently truncates the last element.
- Handle compression transparently. OSM XML is nearly always distributed gzipped, and decompressing to disk first doubles the storage requirement for no benefit.
Verification Jump to heading
- Memory is flat across file sizes. Parse a small and a large file and confirm peak resident memory is comparable.
- Counts match a reference. Compare the node, way and relation counts against
osmium fileinfoon the same file. - The last element is emitted. A file’s final element must appear; if it does not, the finalising parse call is missing.
- Gzipped and plain files agree. Parse both forms of the same data and confirm identical counts.
- Unmodelled children do not crash. Add an unexpected child element and confirm the parse continues.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Memory grows with file size | Elements retained after emitting | Release the current element on its end tag |
| Last element missing | Parse never finalised | Call the parser once more with the final flag set |
| Crash on an unexpected child | Current element assumed present | Check for a current element before handling a child |
| Slow on gzipped input | Decompressed to disk first | Stream through a gzip reader |
| Memory spikes on long text | Character-data handler set | Leave it unset; OSM XML has no meaningful text |
| Counts differ from a reference | Elements counted on start, not end | Count on the end tag, when the element is complete |
| Parser rejects the file | Chunk boundary split a multi-byte sequence | Feed bytes, not decoded strings; expat handles encoding |
Specification reference Jump to heading
The expat parser is an event-driven XML parser that invokes registered handlers as it encounters start tags, end tags and character data, without constructing a document tree. Input may be supplied incrementally, with a final call signalling the end of the document. See the Python expat documentation for the handler interface and the incremental parsing protocol.
Frequently Asked Questions Jump to heading
Why not use an iterative parser with clearing?
It works, and it is the usual recommendation, but it builds an object graph for every element and then relies on the caller remembering to discard it — including the references the root element accumulates, which is the part almost everybody misses. A pull parser builds nothing, so flat memory is a property of the design rather than of remembering a cleanup call inside a loop.
Should I be reading XML at all?
Usually not. PBF is an order of magnitude smaller and several times faster to parse, and every tool in the ecosystem reads it. XML remains necessary for change files in some workflows, for data that only exists as an XML export, and for the API’s own responses. Check whether a PBF form of the same data exists before committing to this path.
How do I turn this into a generator?
Not straightforwardly, because expat drives the parse and calls your handlers rather than yielding to you. The clean approach is to run the parse on a worker thread feeding a bounded queue, which the generator drains — the bounded queue is what preserves flat memory. Collecting everything into a list first, as the simple version does, reintroduces exactly the memory problem the pull parser solved.
Does chunk size matter?
Only within wide limits. A megabyte is comfortably large enough to amortise the per-call overhead and small enough that the buffer is irrelevant against everything else. Very small chunks add measurable call overhead on a large file; very large ones defeat the purpose by holding more in memory than necessary. Anywhere between a hundred kilobytes and a few megabytes behaves identically.
Related Jump to heading
- OSM XML vs PBF Comparison — the parent topic and whether XML is the right input at all.
- Measuring OSM XML vs PBF Parse Throughput — quantifying what the format choice costs.
- Converting OSM XML to PBF with osmium — the usual answer once the data is in hand.
- Memory-Efficient Chunk Processing — the same discipline applied to the binary format.
- Profiling Peak Memory of an OSM Parser — measuring rather than assuming the flatness.
Up one level: OSM XML vs PBF Comparison.