Most turn restrictions pivot on a single node, and most parsers handle exactly that case. The remainder pivot on one or more ways — a slip road between two carriageways, a link through a grade-separated junction — and a parser written for the node case does not fail on them, it ignores them. This page covers resolving those, as part of parsing OSM relations for advanced constraints within OSM Graph Architecture & Network Modeling.
The consequence of ignoring them is specific: the most complex junctions in the network — precisely the ones where a wrong turn is most costly — end up with no restriction enforcement at all.
When to use this approach
Handle via-ways whenever the network contains grade-separated junctions, which in practice means any extract containing a motorway. The count is small in absolute terms and concentrated exactly where errors are expensive.
The signal that a parser is dropping them is a via-way count of zero. Since a real motorway interchange almost always carries at least one, zero is a parser result rather than a data fact.
Implementation
The via members arrive in relation order but not necessarily geometrically ordered, so they need chaining by shared endpoints.
# requires: none beyond the standard library
def chain_via_ways(via_ids: list[int], way_nodes: dict[int, list[int]],
from_id: int, to_id: int) -> list[int] | None:
"""Order via ways into a continuous path between from and to."""
if not via_ids:
return None
remaining = list(via_ids)
# Anchor on whichever end the from-way attaches to
from_ends = {way_nodes[from_id][0], way_nodes[from_id][-1]}
start = next(
(w for w in remaining
if {way_nodes[w][0], way_nodes[w][-1]} & from_ends),
None,
)
if start is None:
return None # from-way does not touch any via
ordered, remaining = [start], [w for w in remaining if w != start]
tail = (set(way_nodes[start][:1] + way_nodes[start][-1:]) - from_ends) or \
{way_nodes[start][-1]}
while remaining:
nxt = next(
(w for w in remaining
if {way_nodes[w][0], way_nodes[w][-1]} & tail),
None,
)
if nxt is None:
return None # chain is broken — relation is malformed
ordered.append(nxt)
remaining = [w for w in remaining if w != nxt]
ends = {way_nodes[nxt][0], way_nodes[nxt][-1]}
tail = (ends - tail) or ends
to_ends = {way_nodes[to_id][0], way_nodes[to_id][-1]}
return ordered if tail & to_ends else None
Returning None on a broken chain rather than raising is deliberate: malformed restriction relations exist in OSM, and a build that aborts on the first one will never complete. Count them, log them, and carry on — a relation that cannot be chained is data to report upstream, not a reason to stop.
The result is stored as an ordered tuple of edge keys rather than a pair:
# requires: none beyond the standard library
def to_path_restriction(from_edge, via_edges: list, to_edge) -> tuple:
"""A prohibited sequence, matched as a whole rather than at its ends."""
return tuple([from_edge, *via_edges, to_edge])
Enforcement then happens with a sliding window over the path being built, rather than with a pair lookup at each expansion:
# requires: none beyond the standard library
def violates(path_edges: list, restrictions: set[tuple]) -> bool:
"""True if the edge sequence contains any prohibited subsequence."""
if not restrictions:
return False
# Restrictions vary in length; check each window size present
lengths = {len(r) for r in restrictions}
for n in lengths:
for i in range(len(path_edges) - n + 1):
if tuple(path_edges[i:i + n]) in restrictions:
return True
return False
The cost of this check is what makes via-way restrictions awkward inside a Dijkstra expansion — it needs the last few edges of the partial path, not just the previous one. In practice the frontier state carries a short tail of recent edges, sized to the longest restriction, which keeps the check O(1) per expansion at the cost of a slightly larger state.
An alternative used by several engines is to expand the restriction into shadow nodes, as described for the via-node case in parsing OSM relations for advanced constraints. For via-ways this means a shadow chain rather than a single shadow node, which is more graph surgery but keeps the search itself unchanged.
Key parameters and tuning
| Parameter | Recommended value | Notes |
|---|---|---|
| Max via ways | 4 | Longer chains exist but are almost always malformed data |
| Broken chain | count and skip | Aborting means a build that never finishes |
| Storage | ordered edge tuple | A pair cannot express a multi-edge prohibition |
| Enforcement | sliding window over a frontier tail | Tail length equals the longest restriction minus one |
| Shadow-chain alternative | where the search cannot carry state | More graph surgery, unchanged search |
| Reporting | via-way count per build | Zero in a motorway region means they are being dropped |
Integration points
With the restriction parser. Via-way relations come out of the same relation() callback as via-node ones and differ only in member type, so the branch is small. What differs is everything downstream, which is why the two should be counted separately from the start.
With the pathfinder. An edge-keyed frontier already carries the previous edge, per handling turn restrictions in routing graphs. Extending it to a short tail is a modest change to the same structure and is far simpler than retrofitting state into a node-keyed search.
With engine builds. OSRM historically ignores via-way restrictions and Valhalla handles them, which is a genuine differentiator for freight work in interchange-heavy networks — a point worth weighing alongside the comparison in OSRM vs Valhalla vs GraphHopper for freight routing.
Validation checklist
- Via-way counts are non-zero where interchanges exist. Compare the parser’s output against
osmium tags-filter -Rcounts on the source PBF. Zero is a parser result, not a data fact. - Chained sequences are geometrically continuous. Assert that consecutive ways in each chain share an endpoint node. A chain that does not is malformed and should be counted, not stored.
- Broken chains are reported, not raised. Confirm the build completes with a non-zero malformed count rather than aborting on the first bad relation.
- A probe route respects the sequence. Construct a request that would traverse the prohibited chain and confirm the router detours. Then construct one sharing the from and to edges via a different intermediate and confirm it is still permitted.
- Frontier tail length matches the longest restriction. If the search carries fewer recent edges than the longest stored tuple, the longest restrictions are unenforceable regardless of being stored.
Related
- Parsing OSM relations for advanced constraints — the handler and member-role resolution these relations arrive through
- Handling turn restrictions in routing graphs — the edge-keyed frontier this extends to a tail
- OSRM vs Valhalla vs GraphHopper for freight routing — engines differ on whether they honour these at all
- Setting turn restrictions in GraphHopper vs OSRM — the probe-pair test that proves a restriction reached the routing graph