Detecting Turn Restriction Errors in OSM Jump to heading
Find the turn restrictions a router will silently ignore, before they become a route that tells a driver to turn where they cannot.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
A turn restriction is a relation with type=restriction, three roled members and a restriction tag naming the rule.
Routers are uniformly forgiving: a relation they cannot interpret is skipped, not reported. That is the right behaviour for a router and it means every defect below has the same visible symptom — the restriction has no effect and nothing says so.
The only_* family deserves separate attention. no_left_turn forbids one movement; only_straight_on forbids every movement except one. A malformed no_* restriction loses one prohibition, while a malformed only_* restriction loses several — so the same defect rate carries more consequence on the only_ half.
Runnable solution Jump to heading
#!/usr/bin/env python3
"""Validate OSM turn restrictions against what a router actually requires."""
from __future__ import annotations
import logging
from collections import defaultdict
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
import osmium
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logger = logging.getLogger(__name__)
NO_TURNS = frozenset({
"no_left_turn", "no_right_turn", "no_straight_on", "no_u_turn",
"no_entry", "no_exit",
})
ONLY_TURNS = frozenset({"only_left_turn", "only_right_turn", "only_straight_on", "only_u_turn"})
VALID_RESTRICTIONS = NO_TURNS | ONLY_TURNS
class Defect(str, Enum):
MISSING_MEMBER = "missing_member"
EXTRA_FROM = "extra_from"
EXTRA_TO_ON_ONLY = "extra_to_on_only"
MEMBER_ABSENT = "member_absent"
VIA_NOT_CONNECTED = "via_not_connected"
UNKNOWN_RESTRICTION = "unknown_restriction"
NOT_A_HIGHWAY = "not_a_highway"
@dataclass
class Finding:
relation: int
defect: Defect
detail: str
class WayIndex(osmium.SimpleHandler):
"""Pass 1 — the endpoints and node set of every highway way.
Only highways: a restriction whose members are not routable ways is itself a
defect, and indexing everything would cost several times the memory.
"""
def __init__(self) -> None:
super().__init__()
self.nodes: dict[int, frozenset[int]] = {}
self.ends: dict[int, tuple[int, int]] = {}
def way(self, w) -> None:
if "highway" not in w.tags or len(w.nodes) < 2:
return
refs = [n.ref for n in w.nodes]
self.nodes[w.id] = frozenset(refs)
self.ends[w.id] = (refs[0], refs[-1])
class RestrictionValidator(osmium.SimpleHandler):
"""Pass 2 — check each restriction relation against the indexed ways."""
def __init__(self, index: WayIndex) -> None:
super().__init__()
self.index = index
self.findings: list[Finding] = []
self.checked = 0
def relation(self, r) -> None:
if r.tags.get("type") != "restriction":
return
self.checked += 1
roles: dict[str, list[tuple[str, int]]] = defaultdict(list)
for member in r.members:
if member.role in ("from", "via", "to"):
roles[member.role].append((member.type, member.ref))
for role in ("from", "via", "to"):
if not roles[role]:
self._add(r.id, Defect.MISSING_MEMBER, f"no {role} member")
return # nothing else is checkable
value = r.tags.get("restriction") or next(
(v for k, v in r.tags if k.startswith("restriction:")), None)
if value not in VALID_RESTRICTIONS:
self._add(r.id, Defect.UNKNOWN_RESTRICTION, f"restriction={value!r}")
# Cardinality. `from` is always exactly one; `to` may be several only
# for no_* restrictions, because only_* names the single permitted exit.
if len(roles["from"]) > 1:
self._add(r.id, Defect.EXTRA_FROM, f"{len(roles['from'])} from members")
if value in ONLY_TURNS and len(roles["to"]) > 1:
self._add(r.id, Defect.EXTRA_TO_ON_ONLY,
f"{value} with {len(roles['to'])} to members")
from_type, from_ref = roles["from"][0]
to_type, to_ref = roles["to"][0]
for label, mtype, ref in (("from", from_type, from_ref), ("to", to_type, to_ref)):
if mtype != "w":
self._add(r.id, Defect.NOT_A_HIGHWAY, f"{label} member is a {mtype}")
return
if ref not in self.index.nodes:
self._add(r.id, Defect.MEMBER_ABSENT, f"{label} way {ref} not a highway here")
return
self._check_via(r.id, roles["via"], from_ref, to_ref)
def _check_via(self, rel_id: int, via: list[tuple[str, int]],
from_ref: int, to_ref: int) -> None:
"""The via must actually join the from way to the to way."""
from_nodes = self.index.nodes[from_ref]
to_nodes = self.index.nodes[to_ref]
if via[0][0] == "n":
node = via[0][1]
if node not in from_nodes:
self._add(rel_id, Defect.VIA_NOT_CONNECTED,
f"via node {node} is not on from way {from_ref}")
if node not in to_nodes:
self._add(rel_id, Defect.VIA_NOT_CONNECTED,
f"via node {node} is not on to way {to_ref}")
return
# A via way chain: from must touch the first, to must touch the last, and
# consecutive via ways must share an endpoint.
via_ways = [ref for mtype, ref in via if mtype == "w"]
missing = [w for w in via_ways if w not in self.index.ends]
if missing:
self._add(rel_id, Defect.MEMBER_ABSENT, f"via way(s) absent: {missing}")
return
if not (from_nodes & self.index.nodes[via_ways[0]]):
self._add(rel_id, Defect.VIA_NOT_CONNECTED,
f"from way {from_ref} does not touch via way {via_ways[0]}")
if not (to_nodes & self.index.nodes[via_ways[-1]]):
self._add(rel_id, Defect.VIA_NOT_CONNECTED,
f"to way {to_ref} does not touch via way {via_ways[-1]}")
for a, b in zip(via_ways, via_ways[1:]):
if not (self.index.nodes[a] & self.index.nodes[b]):
self._add(rel_id, Defect.VIA_NOT_CONNECTED,
f"via ways {a} and {b} are not connected")
def _add(self, rel_id: int, defect: Defect, detail: str) -> None:
self.findings.append(Finding(rel_id, defect, detail))
def validate(path: Path) -> list[Finding]:
index = WayIndex()
index.apply_file(str(path))
logger.info("indexed %d highway way(s)", len(index.nodes))
validator = RestrictionValidator(index)
validator.apply_file(str(path))
by_defect: dict[Defect, int] = defaultdict(int)
for finding in validator.findings:
by_defect[finding.defect] += 1
affected = len({f.relation for f in validator.findings})
logger.info("%d restriction(s) checked, %d affected (%.1f%%)",
validator.checked, affected, 100 * affected / max(validator.checked, 1))
for defect, count in sorted(by_defect.items(), key=lambda kv: -kv[1]):
logger.info(" %-22s %5d", defect.value, count)
return validator.findings
Step-by-step walkthrough Jump to heading
WayIndex stores each highway way’s full node set as a frozenset, not just its endpoints. Connectivity for a via node is a membership test anywhere along the way — a restriction at a mid-block junction is perfectly normal — so endpoints alone would report thousands of false positives.
The relation handler returns early after a missing member. Everything downstream dereferences roles["from"][0], and continuing past a missing role turns a clear finding into an IndexError on a subset of the data.
The restriction: prefix fallback catches conditional and mode-specific forms such as restriction:hgv=no_left_turn, which are valid and would otherwise be reported as missing.
The only_* cardinality check is separate from the from check because the rules genuinely differ. Several to members are legitimate on a no_* restriction — one prohibition covering several departures — and contradictory on an only_*, which by definition names the single permitted exit.
_check_via handles both forms the specification allows. The node form is the common one; the way-chain form appears at dual-carriageway junctions and roundabouts, and it needs the chain to be connected end to end, which is the check most validators omit.
Verification Jump to heading
Cross-check against a router’s own view, which is the only end-to-end confirmation:
# Build a graph and ask it how many restrictions it accepted.
osrm-extract -p car.lua country.osm.pbf 2>&1 | grep -i 'restriction'
The count OSRM reports as usable should be close to your valid count. A large gap in either direction means one of you is interpreting the specification differently, and finding out which is worth the hour.
Then confirm a known-good and a known-bad case by hand:
findings = validate(Path("country.osm.pbf"))
by_relation = {f.relation: f for f in findings}
assert KNOWN_GOOD_RELATION not in by_relation
assert by_relation[KNOWN_BROKEN_RELATION].defect is Defect.VIA_NOT_CONNECTED
Finally, watch the rate rather than the count. A defect rate that jumps between extracts usually means the extract was cut differently — a complete_ways cut drops relation members and manufactures MEMBER_ABSENT findings that say nothing about the map.
Common errors and fixes Jump to heading
| Symptom | Root cause | Fix |
|---|---|---|
Thousands of member_absent findings |
Extract cut with complete_ways |
Re-cut with smart |
False via_not_connected at mid-block junctions |
Only way endpoints indexed | Index the full node set |
IndexError on some relations |
Continued past a missing member | Return early after MISSING_MEMBER |
restriction:hgv reported as unknown |
Prefix form not handled | Fall back to restriction:* keys |
| Memory blows up on a continent | Every way indexed, not just highways | Filter to highway in pass 1 |
| Router accepts fewer than you validate | Router requires more, e.g. no via ways |
Compare against the router’s own rules |
Frequently Asked Questions Jump to heading
Should a broken restriction be repaired automatically?
No. Every defect here is ambiguous in the direction that matters: a via node not on the from way could mean the wrong node, the wrong way, or a junction that has since been re-drawn, and picking one makes a routing rule up. Report them, and fix them upstream in the map — this is a case for the Osmose or JOSM path in Authoring OSM Validation Rules rather than for a pipeline repair.
What about no_u_turn on a single way?
Legitimate and common: from and to are the same way, with a via node at the end where the turn would happen. The validator above handles it because it never assumes from and to differ. A check that rejects same-way restrictions produces a large false-positive rate on any urban extract.
Do restriction relations need the members to be in order?
No — roles carry the meaning, and member order is not significant for from, via and to. Order does matter within a multi-way via chain in practice, because a chain listed out of order is much harder to validate, but the specification does not require it and the connectivity check above does not depend on it.
How does this relate to connectivity checking?
They are complementary and catch different things. The component analysis in Finding Disconnected Road Network Components finds places a router cannot reach at all; this finds places it can reach in ways it should not. A network can be perfectly connected and full of unenforced restrictions.
Specification reference Jump to heading
A turn restriction is a relation tagged
type=restrictionwith arestrictionvalue from theno_*andonly_*families, optionally suffixed by transport mode asrestriction:hgvand similar. It requires exactly one member with rolefrom, exactly one with roleto, and aviamember that is either one node or an ordered, connected sequence of ways.only_*values permit exactly oneto.
Related Jump to heading
- Routing-Graph Topology QA — the topic this check belongs to.
- Finding Disconnected Road Network Components — the complementary connectivity check.
- Node, Way & Relation Data Model — the relation model being validated.
- Choosing complete_ways vs smart in osmium extract — why the cut strategy changes the results.
- Authoring OSM Validation Rules — getting these fixed in the map rather than in your copy.
Up one level: Routing-Graph Topology QA.