Traversing Nested OSM Relations Safely Jump to heading
Relations can contain relations, nothing forbids a cycle, and nothing bounds the depth. A naive recursive walk over a route hierarchy works on every relation you test with and hangs on the one that matters.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
An OSM relation’s members can be nodes, ways or other relations. That last case makes the structure a general directed graph rather than a tree, and three properties follow that a tree-shaped assumption gets wrong.
Cycles are possible. Relation A can contain B, which contains A. Nothing in the data model prevents it and mappers occasionally create it by accident. A recursive walk without a visited set recurses until the stack ends.
Depth is unbounded in principle. Route hierarchies nest three or four deep in practice; nothing guarantees that, and a walk with no ceiling has no bound on its work.
The same relation can be reached by several paths. A member may legitimately appear under two parents, and a walk that does not deduplicate will process it twice, producing doubled geometry or doubled counts.
Runnable solution Jump to heading
from __future__ import annotations
import logging
from collections import deque
from collections.abc import Callable
from dataclasses import dataclass, field
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.relations.traverse")
MAX_DEPTH = 8
MAX_MEMBERS = 250_000
class TraversalLimit(RuntimeError):
"""The traversal hit a guard rather than finishing naturally."""
@dataclass
class Result:
ways: list[int] = field(default_factory=list)
nodes: list[int] = field(default_factory=list)
relations_visited: set[int] = field(default_factory=set)
max_depth_seen: int = 0
cycles: list[tuple[int, int]] = field(default_factory=list)
def traverse(root_id: int,
members_of: Callable[[int], list[tuple[str, int, str]]],
max_depth: int = MAX_DEPTH,
max_members: int = MAX_MEMBERS) -> Result:
"""Breadth-first walk over a relation hierarchy, with every guard explicit.
Iterative rather than recursive: a cycle in the data must not become a
stack overflow, and an iterative walk makes the depth ceiling obvious.
"""
result = Result()
# (relation id, depth, parent id) — the parent is kept for cycle reporting.
queue: deque[tuple[int, int, int | None]] = deque([(root_id, 0, None)])
result.relations_visited.add(root_id)
seen_members = 0
while queue:
relation_id, depth, parent = queue.popleft()
result.max_depth_seen = max(result.max_depth_seen, depth)
if depth >= max_depth:
raise TraversalLimit(
f"relation {relation_id} sits at depth {depth}; the ceiling is "
f"{max_depth}. Either raise it deliberately or the hierarchy "
f"is malformed.")
for member_type, member_id, _role in members_of(relation_id):
seen_members += 1
if seen_members > max_members:
raise TraversalLimit(
f"traversal exceeded {max_members} members after visiting "
f"{len(result.relations_visited)} relation(s)")
if member_type == "way":
result.ways.append(member_id)
elif member_type == "node":
result.nodes.append(member_id)
elif member_type == "relation":
if member_id in result.relations_visited:
# Already reached: either a diamond or a cycle. Record it
# and do NOT descend again.
result.cycles.append((relation_id, member_id))
continue
result.relations_visited.add(member_id)
queue.append((member_id, depth + 1, relation_id))
# De-duplicate: a way can legitimately belong to several relations.
result.ways = sorted(set(result.ways))
result.nodes = sorted(set(result.nodes))
logger.info("visited %d relation(s) to depth %d: %d way(s), %d node(s), "
"%d repeat edge(s)", len(result.relations_visited),
result.max_depth_seen, len(result.ways), len(result.nodes),
len(result.cycles))
return result
def detect_true_cycles(result: Result,
members_of: Callable[[int], list[tuple[str, int, str]]]
) -> list[tuple[int, int]]:
"""Separate genuine cycles from harmless diamonds among the repeat edges."""
genuine: list[tuple[int, int]] = []
for parent, child in result.cycles:
# A true cycle means the child can reach the parent again.
reachable: set[int] = set()
stack = [child]
while stack:
current = stack.pop()
for t, i, _ in members_of(current):
if t != "relation" or i in reachable:
continue
reachable.add(i)
stack.append(i)
if parent in reachable:
genuine.append((parent, child))
if genuine:
logger.error("%d genuine cycle(s): %s", len(genuine), genuine[:5])
return genuine
if __name__ == "__main__":
graph = {1: [("relation", 2, ""), ("way", 10, "")],
2: [("way", 11, ""), ("relation", 1, "")]}
result = traverse(1, lambda rid: graph.get(rid, []))
detect_true_cycles(result, lambda rid: graph.get(rid, []))
Step-by-step walkthrough Jump to heading
- Iterate, do not recurse. A cycle in the data should produce a clear error, not a stack overflow whose traceback says nothing about relations.
- Mark visited on enqueue, not on dequeue. Marking on dequeue lets the same relation be queued several times before any of them is processed, which reintroduces the duplication the set exists to prevent.
- Cap the depth explicitly. Eight levels is far beyond anything legitimate; hitting it means the hierarchy is malformed and the message says so rather than leaving somebody to guess.
- Cap the member count. Depth alone does not bound the work, because a shallow hierarchy can still fan out enormously. A member ceiling is the guard that actually bounds memory.
- Record repeat edges rather than ignoring them. A relation reached twice is either a diamond or a cycle, and recording the edge lets the two be distinguished afterwards without slowing the main walk.
- Deduplicate the results. A way belonging to three route relations appears three times; a consumer counting kilometres will triple them.
- Separate cycles from diamonds after the fact. Only a genuine cycle is a data error worth reporting to mappers, and the reachability check that distinguishes them is too expensive to run inline.
Verification Jump to heading
- A cycle terminates. Build a two-relation cycle and confirm the walk finishes and reports a repeat edge.
- A diamond is not reported as a cycle. Build one and confirm the repeat edge is recorded but the genuine-cycle check returns nothing.
- The depth ceiling fires. Construct a hierarchy deeper than the limit and confirm the error names the depth.
- The member cap fires. Lower the cap and confirm the traversal stops with a count rather than exhausting memory.
- Results are deduplicated. A way in two child relations must appear once in the output.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Traversal never finishes | Cycle with no visited set | Mark relations visited as they are enqueued |
| Stack overflow on deep data | Recursive implementation | Use an iterative queue with an explicit depth |
| Same relation processed twice | Visited marked on dequeue | Mark on enqueue instead |
| Geometry or counts doubled | Results not deduplicated | Collapse way and node identifiers to a set |
| Memory exhausted at shallow depth | Only depth bounded | Add a member-count ceiling |
| Every repeat edge reported as a cycle | Diamonds and cycles conflated | Run a reachability check to separate them |
| Limits hit on legitimate data | Ceilings set too tight | Raise them deliberately, with the reason recorded |
Specification reference Jump to heading
A relation’s members may be nodes, ways or other relations, and the data model places no restriction on nesting depth or on a relation being a member of one it transitively contains. Consumers are therefore responsible for cycle detection and for bounding traversal. See the relation documentation for the member model and the super-relation discussion for how route hierarchies nest in practice.
Frequently Asked Questions Jump to heading
Are cycles in OSM relations actually possible?
Yes. Nothing in the data model or the editing API prevents relation A from containing relation B while B contains A, and mappers create such structures occasionally by accident when reorganising route hierarchies. They are rare, which is why an unguarded traversal survives testing, and they are catastrophic when encountered, which is why the visited set is not optional.
Is a depth limit enough on its own?
No, because depth does not bound breadth. A hierarchy three levels deep whose top relation has five thousand children, each with five thousand members, stays well inside any reasonable depth ceiling while producing tens of millions of members. A member-count cap is the guard that actually bounds memory, and it is the one most often omitted because the depth limit feels sufficient.
How do I tell a cycle from a legitimate shared member?
By checking reachability after the walk. Reaching the same relation twice is ordinary — a way or a sub-relation can belong to several parents, and that diamond shape is entirely valid. It is only a cycle if the child can reach the parent again by following member links. That check is too expensive to run during the traversal and cheap enough to run afterwards on the handful of repeat edges recorded.
Should the traversal be breadth-first or depth-first?
Breadth-first, for two practical reasons. It makes the depth of each relation directly available, which is what the ceiling is checked against, and it naturally uses a queue rather than a call stack, so a pathological structure produces a clear error instead of a stack overflow. The traversal order itself rarely matters for the result, since the output is deduplicated anyway.
Related Jump to heading
- Node, Way & Relation Data Model — the parent topic and the member model this walks.
- Handling Multipolygon Members with No Role — assembly for the flat relation case.
- Resolving Way Node References Without a Full Node Cache — resolving the ways this traversal collects.
- Understanding OSM Multipolygon Relations for GIS — the most common relation type in a pipeline.
- Error Handling in Large OSM Extracts — where a traversal limit failure should be routed.
Up one level: Node, Way & Relation Data Model.