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.


Anatomy of an OSM Restriction Relation Four-stage diagram: relation members with from/via/to roles, the physical via node where edges meet, the augmented turn graph with a shadow node per restricted approach, and the resulting turn-edge table. Anatomy of an OSM Restriction Relation pyosmium resolves member roles into augmented turn-restricted edges Relation type=restriction restriction=no_left_turn from → way 118842 via → node 55291 to → way 118850 Via Node node 55291 3 physical edges meet: A → V (inbound) V → B (outbound) V → C (outbound) Augmented Turn Graph V splits by approach edge V(A): reached via A allowed → C ✕ B blocked V(other): via D, E… allowed → B, C no_left_turn applies only to the A→B transition, not V itself Turn Edge Table (A,V,B) → blocked (A,V,C) → cost 0 (D,V,B) → cost 0 (D,V,C) → cost 0 consumed by the cost function at query time

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, using from/to/sign/location roles instead of from/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 restriction value 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.