Using an LMDB Node Store for OSM Parsing Jump to heading
Building way geometry needs the coordinates of every node a way references, and a continent has more nodes than a dictionary can hold. A memory-mapped key-value store moves that map to disk without moving it out of reach.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
The node store answers one question, hundreds of millions of times: given a node identifier, what is its coordinate? Everything about the design follows from that access pattern.
The keys are dense integers. Node identifiers are assigned sequentially across the planet, so a store keyed on them has excellent locality when reads follow way order — the nodes of one way were usually created together.
The values are tiny and fixed-width. Two 32-bit integers in nanodegree-scaled form hold a coordinate exactly, in eight bytes. A variable-width encoding saves nothing and costs a length.
Writes must be batched and sorted. Committing one transaction per node is orders of magnitude slower than committing one per hundred thousand, and writing in ascending key order lets the store append rather than split pages.
Reads happen inside one long transaction. Opening a read transaction per lookup costs more than the lookup; a single transaction spanning the way pass is both faster and gives a consistent view.
The alternative to a store is a flat array indexed by identifier, which is faster still and sized by the highest identifier rather than by the node count — tens of gigabytes of address space even for a small country. Memory mapping makes both viable; the store is the choice when the identifier space is sparse relative to what you hold.
Runnable solution Jump to heading
from __future__ import annotations
import logging
import struct
from pathlib import Path
import lmdb
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.parse.nodestore")
# Fixed-width, big-endian keys sort in numeric order, which is what makes
# batched ascending writes cheap for the store's page layout.
KEY = struct.Struct(">Q")
VALUE = struct.Struct(">ii") # lat, lon in nanodegree-scaled ints
NANO = 10_000_000 # 1e-7 degree resolution: ~1 cm
BATCH = 100_000
MAP_SIZE = 64 * 1024 ** 3 # address space, not resident memory
class NodeStore:
def __init__(self, path: Path, readonly: bool = False) -> None:
self.env = lmdb.open(
str(path), map_size=MAP_SIZE, subdir=True, readonly=readonly,
# Durability is not needed: the store is a rebuildable cache, and
# disabling the sync is worth several times the write throughput.
sync=False, metasync=False, writemap=True, max_dbs=1)
self._pending: list[tuple[bytes, bytes]] = []
def put(self, node_id: int, lat: float, lon: float) -> None:
self._pending.append((
KEY.pack(node_id),
VALUE.pack(int(round(lat * NANO)), int(round(lon * NANO)))))
if len(self._pending) >= BATCH:
self.flush()
def flush(self) -> None:
if not self._pending:
return
# Sorted, appended: the store can extend pages instead of splitting them.
self._pending.sort()
with self.env.begin(write=True) as txn:
cursor = txn.cursor()
cursor.putmulti(self._pending, dupdata=False, overwrite=True,
append=True)
self._pending.clear()
def reader(self):
"""One long read transaction for a whole pass, not one per lookup."""
return self.env.begin(write=False, buffers=True)
@staticmethod
def get(txn, node_id: int) -> tuple[float, float] | None:
raw = txn.get(KEY.pack(node_id))
if raw is None:
return None
lat, lon = VALUE.unpack(bytes(raw))
return lat / NANO, lon / NANO
def stats(self) -> dict[str, int]:
with self.env.begin() as txn:
info = txn.stat()
logger.info("%d entr(ies), depth %d, %d leaf page(s)",
info["entries"], info["depth"], info["leaf_pages"])
return info
def close(self) -> None:
self.flush()
self.env.close()
def build_store(pbf_path: Path, store_path: Path, keep) -> NodeStore:
"""Pass one: write every node we will need. `keep` filters the subset."""
import osmium
store = NodeStore(store_path)
class Collector(osmium.SimpleHandler):
def node(self, n):
if keep(n):
store.put(n.id, n.location.lat, n.location.lon)
Collector().apply_file(str(pbf_path))
store.flush()
store.stats()
return store
def assemble_ways(pbf_path: Path, store: NodeStore) -> int:
"""Pass two: resolve way references against the store."""
import osmium
built = missing = 0
with store.reader() as txn:
class Builder(osmium.SimpleHandler):
def way(self, w):
nonlocal built, missing
coords = []
for ref in w.nodes:
point = NodeStore.get(txn, ref.ref)
if point is None:
missing += 1
return # incomplete way: do not half-build it
coords.append(point)
if len(coords) >= 2:
built += 1
Builder().apply_file(str(pbf_path))
logger.info("built %d way(s); %d skipped for missing nodes", built, missing)
return built
if __name__ == "__main__":
logger.info("pass one writes nodes, pass two resolves ways against them")
Step-by-step walkthrough Jump to heading
- Use fixed-width big-endian keys. They sort in numeric order, which is what makes an append-mode batch write cheap; a variable-width or little-endian key destroys that ordering.
- Store coordinates as scaled integers. Nanodegree-scaled 32-bit integers hold OSM’s full precision in eight bytes, against sixteen for two doubles, and the conversion is exact in both directions.
- Batch the writes. One transaction per node is catastrophically slow; a hundred thousand per transaction amortises the commit to nothing.
- Sort each batch before writing. Ascending keys let the store append to the end of its page layout rather than splitting pages in the middle, which is several times faster.
- Disable synchronous durability. The store is a rebuildable cache, so surviving a power failure buys nothing and costs a large fraction of the write throughput.
- Open one read transaction per pass. Transaction setup dominates a lookup that is otherwise a pointer dereference into mapped memory.
- Refuse to half-build a way. A way with a missing node reference is incomplete, and emitting the partial geometry produces a road that stops in the middle of nowhere.
- Size the map generously. The map size is address space rather than resident memory, so over-reserving costs nothing while under-reserving fails mid-run.
Verification Jump to heading
- A known node round-trips. Store a coordinate, read it back, and confirm it matches to full precision.
- Entry count matches the filter. The store’s entry count should equal the number of nodes the filter kept.
- Way assembly finds its nodes. A low missing count is expected at an extract’s boundary and a high one means the filter dropped nodes that ways reference.
- Write throughput is sane. Below a hundred thousand nodes per second on local disk, one of the four configuration steps is missing.
- Memory stays flat. Resident memory should stay near the batch size regardless of how many nodes are stored.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Store build takes days | One transaction per node | Batch tens of thousands per transaction |
| Batched writes still slow | Keys written out of order | Sort each batch before writing |
| Throughput limited by disk sync | Durability enabled on a cache | Disable synchronous commits for a rebuildable store |
| Run fails part way | Map size too small | Reserve generous address space; it is not resident memory |
| Lookups slower than expected | A transaction opened per lookup | Open one read transaction for the whole pass |
| Ways stop in the middle of nowhere | Partial geometry emitted | Skip a way with any missing node reference |
| Store far larger than expected | Coordinates stored as doubles | Store nanodegree-scaled integers |
Specification reference Jump to heading
LMDB is a memory-mapped key-value store providing a read-optimised B+ tree with multi-version concurrency, where the map size reserves address space rather than resident memory and readers operate inside long-lived transactions without blocking writers. Keys are compared as byte strings, so fixed-width big-endian integers sort numerically. See the LMDB documentation for the transaction model and the append-mode write optimisation.
Frequently Asked Questions Jump to heading
Why not just use a flat memory-mapped array?
Often you should — it is faster and simpler. The array is indexed directly by node identifier, so it is sized by the highest identifier rather than by how many nodes you keep, which means tens of gigabytes of address space even for a small country. That is fine when you keep every node and wasteful when you keep a filtered subset, which is exactly when a key-value store wins.
Is disabling durability safe?
For this use, yes. The store is derived entirely from a PBF file that still exists, so a power failure means rebuilding it rather than losing anything. Synchronous commits cost a large fraction of write throughput for a guarantee that is worth nothing here. It would obviously be the wrong choice for a store holding data that cannot be regenerated.
Why does sorting each batch matter so much?
Because ascending keys let the store append to the end of its page layout rather than inserting into the middle. An insert in the middle can split a page, which rewrites it and may cascade upward; an append extends the last page. Sorting a batch of a hundred thousand keys costs milliseconds and saves a large multiple of that in page management.
What should happen to a way with a missing node?
Skip it entirely rather than building the part you can. A way at an extract’s boundary legitimately references nodes outside the file, and emitting the portion that resolves produces a road that stops abruptly in open country — geometry that looks real and is not. Counting the skips tells you whether the missing references are boundary effects or a filter that dropped too much.
Related Jump to heading
- Memory-Efficient Chunk Processing — the parent topic and the wider memory strategies.
- Profiling Peak Memory of an OSM Parser — measuring whether the store actually bounded memory.
- Bounded LRU Node Cache for OSM Streaming — the in-memory alternative for a bounded working set.
- Resolving Way Node References Without a Full Node Cache — the problem this store solves.
- Running Planetiler on a Regional Extract — a tool making the same storage choice internally.
Up one level: Memory-Efficient Chunk Processing.