Resolving Way-Node References Without a Full Node Cache Jump to heading
Turn ways into geometries on a machine that cannot hold every node in memory, by holding only the nodes the ways actually reference.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
A way stores node identifiers, not coordinates. Building its geometry means looking up every identifier, and the naive way to make those lookups fast is a dictionary of every node in the file — which for a country extract is tens of gigabytes.
The saving comes from a simple observation: you almost never need every node. A road-network build references the nodes on highway ways and nothing else, which is around twelve percent of a typical extract. Holding twelve percent of the nodes in a compact array instead of one hundred percent in a Python dictionary is two independent order-of-magnitude wins multiplied together.
The cost is an extra read of the file, and that is a good trade. Reads are sequential, cheap, and get faster with better storage; memory is a hard ceiling that fails abruptly.
Runnable solution Jump to heading
#!/usr/bin/env python3
"""Build way geometries holding only the nodes those ways reference."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Callable, Iterator
import numpy as np
import osmium
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logger = logging.getLogger(__name__)
WayFilter = Callable[[osmium.osm.Way], bool]
class NeededNodes(osmium.SimpleHandler):
"""Pass 1 — collect the node ids referenced by the ways we care about."""
def __init__(self, keep: WayFilter) -> None:
super().__init__()
self.keep = keep
self._chunks: list[np.ndarray] = []
self._buffer: list[int] = []
def way(self, w) -> None:
if not self.keep(w):
return
self._buffer.extend(n.ref for n in w.nodes)
if len(self._buffer) >= 4_000_000:
self._flush()
def _flush(self) -> None:
if self._buffer:
self._chunks.append(np.fromiter(self._buffer, dtype=np.int64))
self._buffer.clear()
def ids(self) -> np.ndarray:
"""Sorted, deduplicated — sorting is what makes pass 3 a binary search."""
self._flush()
if not self._chunks:
return np.empty(0, dtype=np.int64)
ids = np.unique(np.concatenate(self._chunks))
logger.info("pass 1: %d distinct node id(s) needed", ids.size)
return ids
class NodeLocations(osmium.SimpleHandler):
"""Pass 2 — record coordinates for exactly those ids, in id order.
Coordinates are kept as the raw int32 nanodegree-scaled values osmium exposes,
which halves the memory against float64 and loses nothing: the scaling is exact.
"""
def __init__(self, wanted: np.ndarray) -> None:
super().__init__()
self.wanted = wanted
self.lon = np.zeros(wanted.size, dtype=np.int32)
self.lat = np.zeros(wanted.size, dtype=np.int32)
self.found = np.zeros(wanted.size, dtype=bool)
def node(self, n) -> None:
pos = np.searchsorted(self.wanted, n.id)
if pos >= self.wanted.size or self.wanted[pos] != n.id:
return # not one we need
loc = n.location
if not loc.valid():
return
self.lon[pos] = loc.x # already in 1e-7 degree units
self.lat[pos] = loc.y
self.found[pos] = True
def report(self) -> None:
missing = int((~self.found).sum())
logger.info("pass 2: %d/%d node(s) located%s", int(self.found.sum()),
self.wanted.size,
f" — {missing} missing (clipped extract?)" if missing else "")
class GeometryBuilder(osmium.SimpleHandler):
"""Pass 3 — resolve each way's references against the arrays and emit geometry."""
SCALE = 1e-7
def __init__(self, keep: WayFilter, ids: np.ndarray,
lon: np.ndarray, lat: np.ndarray, found: np.ndarray) -> None:
super().__init__()
self.keep, self.ids = keep, ids
self.lon, self.lat, self.found = lon, lat, found
self.complete = 0
self.incomplete = 0
self.results: list[tuple[int, np.ndarray]] = []
def way(self, w) -> None:
if not self.keep(w):
return
refs = np.fromiter((n.ref for n in w.nodes), dtype=np.int64)
pos = np.searchsorted(self.ids, refs)
# Guard the lookup: a clipped extract can reference nodes it does not contain.
ok = (pos < self.ids.size) & (self.ids[np.minimum(pos, self.ids.size - 1)] == refs)
ok &= self.found[np.minimum(pos, self.ids.size - 1)]
if not ok.all():
self.incomplete += 1
return
coords = np.stack((self.lon[pos] * self.SCALE, self.lat[pos] * self.SCALE), axis=1)
self.results.append((w.id, coords))
self.complete += 1
def build_geometries(path: Path, keep: WayFilter) -> list[tuple[int, np.ndarray]]:
needed = NeededNodes(keep)
needed.apply_file(str(path))
ids = needed.ids()
locations = NodeLocations(ids)
locations.apply_file(str(path), locations=False)
locations.report()
builder = GeometryBuilder(keep, ids, locations.lon, locations.lat, locations.found)
builder.apply_file(str(path))
logger.info("pass 3: %d complete way(s), %d incomplete",
builder.complete, builder.incomplete)
return builder.results
if __name__ == "__main__":
highways = lambda w: "highway" in w.tags
geometries = build_geometries(Path("ireland.osm.pbf"), highways)
Step-by-step walkthrough Jump to heading
NeededNodes buffers into Python lists and flushes into numpy arrays periodically. Appending to a list is fast and appending to a numpy array is not, so the chunked pattern gets the speed of one and the memory of the other. The final np.unique sorts and deduplicates in one operation, and the sort is not incidental — it is what makes every later lookup a binary search.
NodeLocations stores osmium’s raw loc.x and loc.y, which are already integers in units of 100 nanodegrees. Keeping them as int32 rather than converting to float64 halves the memory and loses no precision, because the conversion is an exact multiplication applied only at the end — the encoding described in PBF File Structure Deep Dive.
Note locations=False on the second apply_file. Asking pyosmium to manage locations itself would allocate exactly the node cache this whole approach exists to avoid.
GeometryBuilder guards the lookup twice: once that the identifier is present in the sorted array, and once that a coordinate was actually found for it. Both matter on a clipped extract, where a way can legitimately reference a node the file does not contain — the referential question at the heart of Extract Clipping & Boundary Polygons. Without the guards, searchsorted returns a plausible neighbouring index and the way gets a coordinate belonging to a different node, silently.
Verification Jump to heading
The incomplete-way count is the number to watch:
ratio = builder.incomplete / (builder.complete + builder.incomplete)
logger.info("%.2f%% of ways could not be resolved", 100 * ratio)
On an extract cut with complete_ways or smart this should be zero. Anything above zero means the extract has dangling references, which is a property of how it was cut, not a bug in this code. On a simple-strategy extract a few percent is normal and is the reason that strategy is discouraged.
Spot-check a geometry against an independent source:
way_id, coords = geometries[0]
print(way_id, coords[:3])
# Compare against: https://www.openstreetmap.org/way/<way_id>
And confirm the memory story actually held:
/usr/bin/time -v python3 build_geometries.py 2>&1 | grep 'Maximum resident'
For a country extract filtered to highways, expect under a gigabyte. Several gigabytes means the filter is matching far more ways than intended — check it before blaming the approach.
Common errors and fixes Jump to heading
| Symptom | Root cause | Fix |
|---|---|---|
| Memory still in the tens of GB | locations=True on a pass |
Pass locations=False; manage them yourself |
| Coordinates land in the wrong place | searchsorted hit unguarded |
Verify ids[pos] == ref before using pos |
| Many incomplete ways | Extract cut with simple |
Re-cut with complete_ways or smart |
| Pass 1 is slow | Appending to a numpy array per node | Buffer in a list, flush in chunks |
| Coordinates are all zero | Node had no valid location | Check loc.valid(); track a found mask |
| Third pass slower than expected | searchsorted called per reference |
Call it once per way, on the whole array |
Frequently Asked Questions Jump to heading
Is three passes really faster than one?
Faster in wall-clock on any machine where the one-pass version would swap, and slower on one where it would not. The point is not raw speed but the ceiling: the three-pass version’s memory is set by how many nodes your ways reference, which you control by filtering, while the one-pass version’s memory is set by the file. On a country extract with a highway filter the three-pass version runs comfortably in under a gigabyte; the dictionary version does not run at all.
Why int32 for coordinates rather than float64?
Because the source data is integers. OSM coordinates are stored as scaled integers with seven decimal places of precision, which fits int32 with room to spare, and converting to float only at the point of use loses nothing while halving the array. For 48 million nodes that is 384 megabytes saved for no cost at all.
When should I reach for osmium's own node cache instead?
When you need most of the nodes anyway, or when the pipeline is already built around osmium’s location handlers. The dense id array is sized by the highest node identifier in the file rather than by how many you need, which is why it loses here — but if you genuinely need every node, that overhead disappears and its constant-time lookup beats a binary search.
Can the passes be parallelised?
Pass 2 and pass 3 can, because PBF blocks are independently decodable, as covered in Speed Up OSM Parsing with Multiprocessing in Python. Share the sorted id array read-only across workers — it is a numpy array, so it can be memory-mapped rather than pickled, and each worker writes into a disjoint slice of the coordinate arrays.
Specification reference Jump to heading
pyosmium exposes node coordinates through
Node.location, whosexandyattributes are the raw values in units of 100 nanodegrees (1e-7 degrees), andlon/latas converted floats.apply_file(path, locations=False)disables the built-in location cache, leaving reference resolution to the caller.
Related Jump to heading
- Node, Way & Relation Data Model — the reference model this resolves.
- Bounded LRU Node Cache for OSM Streaming — the single-pass alternative, with a cache.
- PBF File Structure Deep Dive — the integer coordinate encoding kept here.
- Extract Clipping & Boundary Polygons — where dangling references come from.
- Memory-Efficient Chunk Processing — the surrounding memory discipline.
Up one level: Node, Way & Relation Data Model.