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.

Two edges against four A via-node restriction prohibits one ordered pair of edges meeting at a node. A via-way restriction prohibits an ordered sequence of four edges through two intermediate ways, which no single pair can express. What the relation actually prohibits via node from to one ordered pair — (from, to) via ways from via 1 via 2 to an ordered 4-tuple, not a pair Storing only (from, to) would forbid that combination even when reached by a different intermediate path — blocking legal movements. The whole sequence has to be matched, which makes this a path restriction rather than a turn restriction.

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.

Where via-way restrictions actually occur A rural extract contains almost no via-way restrictions, a mixed regional extract a handful, and a metropolitan extract with several motorway interchanges over two hundred — a small share of the total but concentrated at the highest-consequence junctions. Restrictions by via member type, three extracts via node via way rural county 412 node, 2 way mixed region 1 940 node, 31 way metro + interchanges 3 100 node, 214 way Six percent of restrictions in the metro extract — and they protect the junctions where a wrong turn costs a driver several kilometres. A parser reporting zero via-ways on that extract is not describing the data; it is describing itself. What a longer restriction tail costs the search Settled-set size grows with the length of edge history the frontier carries: an edge-keyed search is about 2.4 times a node-keyed one, and carrying a four-edge tail for long via-way chains reaches about six times. Frontier state size against the longest restriction handled node-keyed, no restrictions 1 entry per node edge-keyed, via-node ≈ 2.4× entries tail of 2, via-way ≈ 3.6× entries tail of 4, long chains ≈ 6.1× entries Size the tail from the longest restriction actually present rather than from the longest imaginable — most extracts need two, not four. A restriction longer than the tail is stored and never enforced, which is the worst of both: the memory cost without the protection.

Validation checklist

  • Via-way counts are non-zero where interchanges exist. Compare the parser’s output against osmium tags-filter -R counts 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.