Turn restrictions are the best-known OSM relation, but they are one member of a broader family — restriction:conditional, destination_sign, and tagged exceptions all attach constraints to a road network the same structural way: through an ordered list of member roles rather than a tag on a single way. This page is part of the OSM Graph Architecture & Network Modeling guide and covers the general mechanics of parsing OSM relations with pyosmium — the handler model, member-role resolution, and the graph-augmentation pattern that turns a from/via/to triple into edges a router can actually enforce. If you have already built a directed graph from raw OSM data using the pattern in building directed graphs from OSM PBF files, this page shows how to overlay relation-derived constraints onto that graph. For the turn-cost matrix and engine-specific application of restriction relations, see handling turn restrictions in routing graphs; for the condition-string grammar used by restriction:conditional and similar tags, see parsing conditional access restrictions.
Prerequisites
Relation parsing is cheap on regional extracts and memory-sensitive on country or planet files. Confirm the following before writing a handler.
System requirements
| Resource | Minimum | Recommended |
|---|---|---|
| Python | 3.9 | 3.11+ |
| pyosmium | 3.6 | Latest stable (bundles libosmium; no separate C++ toolchain needed) |
| RAM | 2 GB for a metro extract | 16 GB+ for state/country extracts with sparse_mmap_array |
| Disk (planet-scale only) | 2× PBF size for scratch index files | NVMe SSD |
Python environment
# Install client-side dependencies (Python 3.9+)
pip install pyosmium pandas pyarrow networkx
OSM data source
Use the same regional PBF extract from Geofabrik that feeds your directed-graph build. If you extracted a bounding box with osmium extract, use the complete_ways or smart strategy — a plain bounding-box cut silently drops relations whose members straddle the boundary, which is the single most common cause of “missing” restrictions downstream.
Conceptual Architecture
An OSM relation is not a geometry — it is an ordered list of members, each a (type, ref, role) triple, plus a set of tags on the relation itself. For a turn restriction, the tags carry the manoeuvre (restriction=no_left_turn) and the members carry the geometry references: from and to point at ways, via points at a node (or, for multi-segment junctions, one or more ways). Unlike a highway tag, which lives entirely on one way, a restriction relation only makes sense once you resolve its members against the ways and nodes they point to — the relation itself contains no coordinates.
pyosmium’s SimpleHandler streams a PBF file once, dispatching node(), way(), and relation() callbacks as each object type is encountered. This matters for relation parsing because standard OSM PBF files are sorted: every node appears before every way, and every way appears before every relation. That ordering guarantee means a single streaming pass is sufficient — cache each way’s node list during way(), and by the time relation() fires for a type=restriction relation, every way it references has already been cached. You do not need a two-pass read unless you are working with an unsorted or hand-edited file, in which case pyosmium will simply fail to resolve forward references and you should re-sort with osmium sort first.
Location data is a separate concern from member resolution. If you only need node and way ids to key into a graph you already built (the common case when overlaying constraints on an existing directed graph), the way-node cache built in way() is enough. If you need actual coordinates — to compute via-way chain order, or to snap a destination_sign location to the nearest node — wrap the handler with osmium.NodeLocationsForWays backed by an osmium.index location map, which resolves way.nodes[i].location during the same pass.
Three relation types cover most routing constraints:
type=restriction— unconditional turn manoeuvres (no_left_turn,only_straight_on, and related values).type=restriction:conditional— the same manoeuvre gated by a time window, vehicle class, or other condition string, detailed in parsing conditional access restrictions.type=destination_sign— destination-based lane or exit guidance, rare but present on some motorway datasets, usingfrom/to/sign/locationroles instead offrom/via/to.
The output of parsing is not a modified OSM object — it is a set of augmented edges laid over the directed graph, which is what the Configuration Reference and implementation steps below build toward.
Step-by-Step Implementation
1. Set Up a Single-Pass pyosmium Handler
# requires: pyosmium (pip install pyosmium)
import osmium
from collections import defaultdict
RESTRICTION_TYPES = {"restriction", "restriction:conditional"}
class RelationConstraintHandler(osmium.SimpleHandler):
"""Single-pass handler. PBF files are node/way/relation-sorted, so
caching way node lists in way() is sufficient before relation() fires."""
def __init__(self):
super().__init__()
self.way_nodes: dict[int, list[int]] = {}
self.way_endpoints: dict[int, tuple[int, int]] = {}
self.restrictions: list[dict] = []
self.skipped = 0
def way(self, w):
node_ids = [n.ref for n in w.nodes]
if len(node_ids) < 2:
return
self.way_nodes[w.id] = node_ids
self.way_endpoints[w.id] = (node_ids[0], node_ids[-1])
def relation(self, r):
rel_type = r.tags.get("type")
if rel_type not in RESTRICTION_TYPES:
return
self._handle_restriction(r, rel_type)
2. Cache Way Node Lists During the Way Callback
The way() callback above already does the caching; the reason it matters is worth making explicit. Without it, relation() would only have raw member ids (from=118842) with no way to determine which node is shared with via or where the manoeuvre physically starts. Keep the cache keyed by way id and avoid storing full node objects — the id list is enough for graph-side resolution and keeps memory proportional to way count rather than to way count times average vertex count.
3. Filter Relations by Type and Role
# continues RelationConstraintHandler
def _handle_restriction(self, r, rel_type):
members = defaultdict(list)
for m in r.members:
members[m.role].append((m.type, m.ref))
if not members["from"] or not members["via"] or not members["to"]:
self.skipped += 1
return # incomplete relation — log and skip, do not raise
self.restrictions.append({
"relation_id": r.id,
"rel_type": rel_type,
"restriction": r.tags.get("restriction") or r.tags.get("restriction:conditional"),
"condition": r.tags.get("restriction:conditional"),
"except": r.tags.get("except"),
"from_ways": [ref for (t, ref) in members["from"] if t == "w"],
"via": members["via"], # [(type, ref), ...] — node or chained ways
"to_ways": [ref for (t, ref) in members["to"] if t == "w"],
})
Filtering on type before touching restriction avoids false negatives — some editors write type=restriction with the manoeuvre value on a differently-cased or legacy tag (day_on/day_off/hour_on/hour_off predate the restriction:conditional convention). Capture except at this stage too; it is a relation-level tag, not a member, and applies to the whole manoeuvre (e.g. except=psv;bicycle exempts buses and bikes from a no_left_turn).
4. Resolve From/Via/To Members to Graph Elements
Most relations have a single via node. Some — commonly at roundabouts and multi-lane slip roads — chain two or more via ways in member order. Resolve both cases into a flat node path using the shared-endpoint pattern:
# requires: pyosmium (uses handler.way_endpoints from step 1)
def resolve_via_path(rec: dict, way_endpoints: dict[int, tuple[int, int]]) -> list[int]:
"""Return the ordered node path from the from-way endpoint through
all via members to the to-way endpoint."""
via_nodes = [ref for (t, ref) in rec["via"] if t == "n"]
if via_nodes:
return via_nodes # simple case: single via node
# Chained via-ways: concatenate by shared endpoint
via_way_ids = [ref for (t, ref) in rec["via"] if t == "w"]
path: list[int] = []
for way_id in via_way_ids:
start, end = way_endpoints[way_id]
if path and path[-1] == start:
path.append(end)
elif path and path[-1] == end:
path.append(start)
else:
path.extend([start, end])
return path
If resolve_via_path cannot find a shared endpoint between consecutive via-ways, the relation is malformed (a common upstream editing mistake) — log the relation_id and exclude it rather than guessing a path.
5. Build Augmented Turn Edges
The via node itself should never carry the restriction — every unrelated approach must still pass through it freely. Instead, duplicate the via node into a shadow node reached only from the restricted from edge, and rewire only that shadow node’s outgoing edges:
# requires: networkx (pip install networkx)
import networkx as nx
def augment_graph_with_restriction(
G: nx.DiGraph, from_node: int, via_node: int, to_node: int, allow: bool
) -> str:
"""Split via_node into a shadow node reached only from from_node.
allow=False removes the (from,via,to) transition (no_*);
allow=True keeps only that transition (only_*)."""
shadow = f"{via_node}__via__{from_node}"
G.add_node(shadow, **G.nodes[via_node])
edge_data = G.edges[from_node, via_node]
G.remove_edge(from_node, via_node)
G.add_edge(from_node, shadow, **edge_data)
for _, target, data in G.out_edges(via_node, data=True):
is_restricted_target = target == to_node
keep = (not allow and not is_restricted_target) or (allow and is_restricted_target)
if keep:
G.add_edge(shadow, target, **data)
return shadow
Call this once per restriction record, resolving allow from the tag value: restriction values starting with no_ pass allow=False; values starting with only_ pass allow=True. Every other approach to via_node — from ways not named in the relation — keeps its original edges untouched, so unrelated traffic through the junction is unaffected.
6. Serialize the Turn-Edge Table
# requires: pandas, pyarrow (pip install pandas pyarrow)
import pandas as pd
def export_turn_edges(records: list[dict]) -> pd.DataFrame:
df = pd.DataFrame(records)
df.to_parquet("turn_restrictions.parquet", index=False)
print(f"Exported {len(df)} restriction records")
return df
Exporting to a flat table decouples relation parsing from the routing engine build step — the turn-cost matrix compilation and engine-specific loaders can consume this Parquet file without re-reading the PBF.
Configuration Reference
Relation Types
type tag |
Purpose | Key relation tags | Notes |
|---|---|---|---|
restriction |
Unconditional turn manoeuvre | restriction=no_left_turn, no_right_turn, no_straight_on, no_u_turn, no_entry, no_exit, only_left_turn, only_right_turn, only_straight_on |
Most common constraint relation; requires from/via/to |
restriction:conditional |
Time- or vehicle-gated turn manoeuvre | restriction:conditional=no_left_turn @ (Mo-Fr 07:00-19:00) |
Condition grammar covered in parsing conditional access restrictions |
destination_sign |
Destination-based lane or exit guidance | destination=*, distance=* |
Rare; uses sign/location roles instead of via |
Member Roles
| Role | Member type | Meaning |
|---|---|---|
from |
way | Edge the vehicle travels on before the pivot point |
via |
node, or one or more ways | Pivot point(s); multiple via ways chain a restriction across a junction sequence |
to |
way | Edge the manoeuvre restricts (no_*) or mandates (only_*) |
location / sign |
node (destination_sign only) |
Position and rendered content of a destination sign |
The except tag is not a member — it lives on the relation and lists vehicle classes exempted from the manoeuvre, e.g. except=psv;bicycle.
Production Optimization and Scaling
Pre-filter before parsing. osmium tags-filter with the -R flag keeps referenced objects, not just tag matches, so relation members survive the cut:
# Keep restriction relations and their way/node members, drop everything else
osmium tags-filter -R region.osm.pbf r/type=restriction r/type=restriction:conditional \
-o region.constraints.pbf
This typically shrinks a country extract by 90%+ before it reaches your handler, since the vast majority of ways in a PBF carry no restriction membership.
Switch index backends for large extracts. The default in-memory location index holds every node coordinate in RAM. For state, country, or planet-scale files, use a memory-mapped index instead:
# requires: pyosmium
import osmium
idx = osmium.index.create_map("sparse_mmap_array")
location_handler = osmium.NodeLocationsForWays(idx)
handler = RelationConstraintHandler()
osmium.apply("region.osm.pbf", location_handler, handler)
sparse_mmap_array trades a small amount of query latency for a scratch file on disk instead of resident RAM, which is the difference between a planet-scale relation pass completing and being OOM-killed on a typical build box.
Partition by region for multiprocessing. Restriction relations rarely span administrative boundaries, so splitting a country extract into regional PBFs and running one handler process per region (via multiprocessing.Pool) parallelizes cleanly. Merge results by relation_id; the rare cross-boundary relation will appear in both region outputs and should be deduplicated on merge, keeping the copy with a fully resolved via path.
Apply incremental diffs instead of full re-parses. Once the initial constraint table is built, apply .osc.gz minutely or daily diffs from Geofabrik with pyosmium-up-to-date rather than re-running the full handler against a fresh planet download. A diff may reference a way or node not included in the diff itself (because it did not change); resolve those references against your existing way-node cache rather than treating them as missing.
| Extract scale | Restriction relation count (approx.) | Recommended index |
|---|---|---|
| Metro area | 500 – 5,000 | flex_mem (default) |
| Country | 20,000 – 300,000 | sparse_mmap_array |
| Planet | ~3 – 4 million | sparse_mmap_array + regional partitioning |
Validation and Testing
# requires: pyosmium, networkx
def validate_restrictions(records: list[dict]) -> None:
"""Structural checks before augmenting the graph."""
missing_via = [r for r in records if not r["via"]]
missing_to = [r for r in records if not r["to_ways"]]
missing_restriction_value = [r for r in records if not r["restriction"]]
assert not missing_via, f"{len(missing_via)} relations missing a via member"
assert not missing_to, f"{len(missing_to)} relations missing a to member"
assert not missing_restriction_value, (
f"{len(missing_restriction_value)} relations missing a restriction value"
)
print(f"[PASS] {len(records)} restriction relations resolved with complete from/via/to members")
def validate_augmentation(G, from_node: int, shadow: str, blocked_target: int) -> None:
"""Confirm the shadow node correctly excludes (or exclusively keeps) a target."""
assert G.has_edge(from_node, shadow), "From-edge was not rerouted onto the shadow node"
assert not G.has_edge(shadow, blocked_target), "Blocked manoeuvre is still routable"
print(f"[PASS] Shadow node {shadow} correctly excludes edge to {blocked_target}")
def validate_unrelated_approaches_unaffected(G, via_node: int, other_from: int) -> None:
"""Confirm an approach not named in the relation keeps its full edge set."""
assert G.has_edge(other_from, via_node), "Unrelated approach was incorrectly rewired"
print(f"[PASS] Approach from {other_from} to {via_node} left untouched")
Sanity checks to run before shipping to production:
- Compare relation counts by
restrictionvalue against a fresh Overpass query for the same bounding box — a large delta signals an extract or filter problem, not a parsing bug. - Run a shortest-path query through a known restricted junction before and after augmentation and confirm the blocked manoeuvre disappears from the result set.
- Spot-check that
self.skipped(from the handler in Step 3) stays under 1-2% of total restriction relations; a higher skip rate usually means the PBF extract dropped members at its boundary.
Troubleshooting
relation() never fires for a restriction I can see in OSM data
A PBF extract cut with a simple bounding-box strategy drops relations whose members fall outside the box, sometimes silently. Re-extract with osmium extract --strategy complete_ways (or smart) so referenced ways are pulled in complete, and confirm survival with osmium tags-filter -R input.pbf r/type=restriction.
The via role appears more than once on a single relation
This is not a mapping error. Some junctions — roundabouts and multi-lane slip-road complexes in particular — require a restriction to pass through more than one intermediate way. Concatenate the via members in member-list order (pyosmium preserves the order r.members returns) and use shared endpoints, as in Step 4’s resolve_via_path, to build a continuous node path before augmenting the graph.
MemoryError or the process is OOM-killed on a country or planet extract
The default flex_mem index keeps all node locations resident in RAM. Switch to osmium.index.create_map("sparse_mmap_array"), which memory-maps a scratch file instead, and pre-filter the PBF with osmium tags-filter -R to drop ways and relations unrelated to routing constraints before the handler ever sees them.
Two restriction relations conflict at the same via node
This usually follows an imperfect edit merge — for example, a no_left_turn and an only_straight_on both anchored on the same from/via pair. Resolve deterministically: prefer the relation with the later changeset timestamp, log the discarded one, and re-run augmentation. Applying both silently double-removes edges and can disconnect the via node entirely.
The augmented graph reports no route through a junction that used to be reachable
For only_left_turn, only_right_turn, and only_straight_on relations, the shadow node must retain only the specified outgoing edge, not “all edges except one.” Check that allow=True was passed for only_* values in Step 5 — implementing every restriction as a blocklist over-connects or under-connects the shadow node depending on the manoeuvre type.
Related
- Parsing Conditional Access Restrictions — the condition-string grammar behind
restriction:conditionaland time-aware access tags - Handling Turn Restrictions in Routing Graphs — compiling resolved restriction relations into a turn-cost matrix and applying it in GraphHopper and OSRM
- Building Directed Graphs from OSM PBF Files — the base graph that augmented turn edges are overlaid onto
- Configuring Edge Weights for Freight Logistics — combining relation-derived constraints with
hgvandmaxweightedge costs for heavy-vehicle routing