Clipping Extracts Without Breaking Boundary Ways
Clipping a continental file down to the region you actually serve is the obvious first step of any OSM pipeline, and the obvious way to do it produces a graph with a ring of damage around its edge. A simple bounding-box extract keeps nodes inside the box and drops those outside, which severs every way crossing the boundary — the motorway that leaves the region loses its far endpoints, its segments lose their geometry, and the junction where it met the local network becomes a dead end. Routing then works perfectly in the middle and fails in a band around the outside, which is the hardest kind of failure to attribute because the graph is not obviously broken.
Prerequisites & Versions
Extraction is done by the osmium toolchain before Python sees the data.
| Requirement | Minimum version | Install |
|---|---|---|
| Python | 3.11 | — |
| osmium-tool | 1.16 | apt install osmium-tool |
| PyOsmium | 3.6 | pip install "osmium>=3.6" |
| neo4j (async driver) | 5.20 | pip install "neo4j>=5.20" |
Implementation
import subprocess
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class ExtractSpec:
source: Path
output: Path
poly: Path # a polygon file, not a bounding box
buffer_km: float = 25.0 # routing room outside the served area
class Extractor:
"""Wraps `osmium extract` with the strategy that keeps ways whole.
`complete_ways` keeps every node referenced by any way that has at least one
node inside the region — so a road crossing the boundary retains its full
geometry rather than being truncated at the edge. The file is larger than a
simple clip and the difference is exactly the data that makes the boundary
routable.
"""
STRATEGIES = {
# Keeps only nodes inside the region. Smallest output, severed ways.
"simple": [],
# Keeps all nodes of any way with a node inside. What routing needs.
"complete_ways": ["--strategy=complete_ways"],
# Also keeps relations' members. Needed for turn restrictions that
# reference a way just outside the boundary.
"smart": ["--strategy=smart", "--option=types=multipolygon,restriction"],
}
def run(self, spec: ExtractSpec, strategy: str = "smart") -> Path:
if strategy not in self.STRATEGIES:
raise ValueError(f"unknown strategy: {strategy}")
cmd = [
"osmium", "extract",
"--polygon", str(spec.poly),
*self.STRATEGIES[strategy],
"--overwrite",
"-o", str(spec.output),
str(spec.source),
]
subprocess.run(cmd, check=True)
return spec.output
# After import, the damage a bad extract leaves is detectable in the graph.
BOUNDARY_DAMAGE = """
// Dead ends within the buffer are expected at the very edge and suspicious
// anywhere inside it. A cluster of them along a straight line is a clipped
// boundary rather than a real set of cul-de-sacs.
MATCH (n:Junction)
WHERE size([(n)-[:SEGMENT]-() | 1]) = 1
AND n.location.latitude >= $min_lat AND n.location.latitude <= $max_lat
AND n.location.longitude >= $min_lon AND n.location.longitude <= $max_lon
RETURN count(n) AS dead_ends
"""
SEVERED_TRUNK = """
// A motorway or trunk road ending in a dead end is almost never real.
MATCH (n:Junction)-[s:SEGMENT]-()
WHERE s.highway IN ['motorway', 'trunk', 'primary']
WITH n, count(s) AS degree
WHERE degree = 1
RETURN n.id AS junction, n.location AS location
ORDER BY junction
"""
How It Works
The buffer and the strategy solve different halves of the problem. The buffer gives the router room to leave the served area and come back — a route between two points near the edge frequently goes outside it, and without the surrounding roads that route does not exist. The strategy decides whether the ways that do cross the buffer’s own boundary survive intact. Both are needed: a generous buffer with a simple clip just moves the ring of damage further out.
complete_ways keeps every node of a partially-included way. That is the property that matters, because a way’s nodes carry its geometry. Truncating a way at the boundary leaves segments whose length and bearing are computed from a coordinate that was never loaded, and the pipeline either drops them or writes something wrong — the PyOsmium guard that skips ways with unresolved node locations exists precisely for what a simple clip produces.
smart additionally keeps relation members. Turn restrictions are relations referencing two ways and a junction node, and a restriction whose via way sits just outside the boundary is dropped by complete_ways — leaving the junction with no restriction, which is a silently permitted illegal turn. On a graph that models turn restrictions as edges, that is a fabricated movement of exactly the kind that arrangement was chosen to make impossible.
Common Failure Patterns
1. Clipping to the served area rather than to a buffered one. A delivery region and a routing region are different things: a route between two addresses in the same district can legitimately leave it, and if the surrounding roads are absent the router either fails or returns an absurd detour along the boundary. Twenty-five kilometres is a reasonable default for road routing and should be larger where the network is sparse.
2. Using a bounding box where a polygon is meant. A box around an irregular region includes large areas nobody serves — which costs import time and store — while still cutting through the parts of the region that stick out. A polygon file costs nothing extra and both fits better and clips less.
3. Trusting the import’s own counts. A severed extract imports cleanly, reports plausible node and relationship totals, and passes every schema check. The damage is in the topology, not in the counts, and the queries above are what expose it — particularly the trunk-road check, since a motorway ending in a dead end is essentially never a real feature.
// Dead ends per distance band from the boundary. A real network has them
// scattered; a severed extract has them concentrated in the outermost band.
MATCH (n:Junction)
WHERE size([(n)-[:SEGMENT]-() | 1]) = 1
RETURN n.boundary_distance_km / 5 AS band, count(*) AS dead_ends
ORDER BY band;
Performance Notes
The strategies differ in both extraction cost and output size, and the ordering is the same for both:
$$|F_{\text{simple}}| < |F_{\text{complete}}| < |F_{\text{smart}}|$$
On a metropolitan extract from a continental source, complete_ways typically produces a file some 5–15 per cent larger than a simple clip and takes roughly twice as long to produce, because it needs a second pass to collect the referenced nodes. smart adds a further pass for relations and a few per cent more. Against an import that takes tens of minutes and a store measured in gigabytes, those are rounding errors — and they buy the removal of an entire class of boundary defect.
The buffer is the more consequential size decision. Extract area grows with the square of the buffer, so doubling it from 25 to 50 km on a compact region can double the imported graph. The right way to choose it is from routing behaviour rather than from instinct: sample real origin-destination pairs, route them against an unclipped graph, and measure how far outside the served region the routes actually stray. On dense urban networks that is usually under ten kilometres; on rural ones with few through routes it can be far more, because the only sensible path between two nearby villages may loop a long way around.
One further habit is worth adopting: keep the polygon and the strategy in the pipeline’s configuration rather than in a shell script someone ran once. The extract is the step every downstream defect is eventually traced back to, and being able to say exactly which polygon and which strategy produced the graph currently in production is the difference between a five-minute diagnosis and an afternoon.
Two operational habits follow from all of this. The first is to treat the extract as a versioned artefact rather than a step: record the source file’s timestamp, the polygon, the strategy and the buffer alongside the resulting graph, so the question “which extract is production running on” has an answer. Every boundary defect is eventually traced back to this step, and the trace is only possible if the inputs were recorded at the time.
The second is to run the damage queries as an acceptance gate rather than as a diagnostic. Dead-end counts and severed trunk roads are cheap to compute immediately after import, they are stable between imports of the same region, and a sudden jump in either is a far better signal than anything a routing test will produce — because a routing test only fails if a sampled route happens to touch the damaged band, while the counts see the whole boundary at once. Gating the promotion of a freshly imported graph on those numbers turns a silent regression into a failed import, which is where you want it.
Related
- OSM Data Ingestion Pipelines — the pipeline this is the first step of.
- Parsing OSM PBF Extracts with PyOsmium — the unresolved-location guard a severed extract triggers.
- Modeling Turn Restrictions as an Edge-Based Graph — why a dropped relation becomes a permitted illegal turn.
- Handling Bridges, Tunnels and Level Crossings — the other tag-driven topology trap in the same pipeline stage.
This guide is part of OSM Data Ingestion Pipelines, within Spatial Graph Construction & OSM Ingestion.