Validating Oneway and Access Tags for Routing Jump to heading
A single reversed oneway tag can make a city block unreachable, and nothing about the data looks wrong: the geometry is valid, the tags parse, and the router simply refuses to go there.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
Directional and access tagging is validated against a profile, not in the abstract. oneway=yes restricts cars and, unless oneway:bicycle=no says otherwise, often bicycles too; it means nothing to a pedestrian. A check that reports “this way is oneway” has found nothing. A check that reports “under the car profile, this way’s direction makes eleven addresses unreachable” has found something.
Three classes of problem are worth separating.
Syntactic problems are values the profile cannot interpret: oneway=1, oneway=true, access=allowed, a maxspeed in a form nothing parses. These are cheap to find, unambiguous, and usually a small fixed list of variants once you look.
Structural problems are consistent tagging that produces an impossible graph. A reversed oneway on a short link creates a pocket that can be entered and not left, or reached only by an absurd detour. These do not show up as invalid anything; they show up as connectivity.
Semantic problems are tags that are individually valid and collectively contradictory: access=no on a way with bicycle=yes and foot=no, or a oneway on a roundabout that opposes the others in the same loop. These need the profile’s precedence rules to detect, which is why the check has to share those rules with the router rather than reimplement them.
The structural class is where the real damage is, and finding it needs a graph rather than a tag scan: strongly connected components under the profile’s directional rules. A healthy road network for cars is very nearly one giant strongly connected component, and everything outside it is either a genuine dead end or a bug.
Runnable solution Jump to heading
from __future__ import annotations
import logging
from collections.abc import Iterable, Mapping
from dataclasses import dataclass, field
import networkx as nx
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.routing.access")
# Values the specification recognises. Anything else is a finding, not a guess.
ONEWAY_FORWARD = {"yes", "true", "1"}
ONEWAY_REVERSE = {"-1", "reverse"}
ONEWAY_NONE = {"no", "false", "0"}
ACCESS_DENY = {"no", "private", "agricultural", "forestry", "delivery",
"customers", "permit"}
ACCESS_ALLOW = {"yes", "designated", "permissive", "destination", "official"}
@dataclass(frozen=True)
class Profile:
"""Which keys the router reads, in precedence order. Must match the router."""
name: str
access_keys: tuple[str, ...] # most specific LAST
honours_oneway: bool = True
oneway_exception_key: str | None = None # e.g. "oneway:bicycle"
CAR = Profile("car", ("access", "vehicle", "motor_vehicle", "motorcar"))
BIKE = Profile("bike", ("access", "vehicle", "bicycle"),
oneway_exception_key="oneway:bicycle")
FOOT = Profile("foot", ("access", "foot"), honours_oneway=False)
@dataclass
class Findings:
unparseable: list[tuple[int, str, str]] = field(default_factory=list)
contradictory: list[tuple[int, str]] = field(default_factory=list)
unreachable: list[int] = field(default_factory=list)
inescapable: list[int] = field(default_factory=list)
def direction(tags: Mapping[str, str], profile: Profile
) -> tuple[bool, bool, str | None]:
"""Return (forward_allowed, backward_allowed, problem)."""
if not profile.honours_oneway:
return True, True, None
value = tags.get("oneway", "no").strip().lower()
if profile.oneway_exception_key:
value = tags.get(profile.oneway_exception_key, value).strip().lower()
if value in ONEWAY_NONE:
return True, True, None
if value in ONEWAY_FORWARD:
return True, False, None
if value in ONEWAY_REVERSE:
return False, True, None
# Never guess: an unrecognised value is a finding, and treating it as
# bidirectional invents a route while treating it as oneway removes one.
return True, True, f"unparseable oneway value {value!r}"
def accessible(tags: Mapping[str, str], profile: Profile
) -> tuple[bool, str | None]:
"""Apply the profile's keys in order; the most specific one present wins."""
allowed: bool | None = None
seen: list[tuple[str, str]] = []
for key in profile.access_keys:
value = tags.get(key)
if value is None:
continue
value = value.strip().lower()
seen.append((key, value))
if value in ACCESS_DENY:
allowed = False
elif value in ACCESS_ALLOW:
allowed = True
else:
return True, f"unparseable {key} value {value!r}"
if len(seen) > 1 and len({v for _, v in seen}) > 1:
# Not an error by itself — specific overrides general is the point —
# but worth surfacing when the general key denies and no specific
# key re-permits for THIS profile.
general = dict(seen).get("access")
if general in ACCESS_DENY and allowed is False:
return False, None
return (True if allowed is None else allowed), None
def build(ways: Iterable[tuple[int, list[int], Mapping[str, str]]],
profile: Profile, findings: Findings) -> nx.DiGraph:
graph = nx.DiGraph()
for way_id, nodes, tags in ways:
ok, problem = accessible(tags, profile)
if problem:
findings.unparseable.append((way_id, "access", problem))
if not ok:
continue
forward, backward, problem = direction(tags, profile)
if problem:
findings.unparseable.append((way_id, "oneway", problem))
for a, b in zip(nodes, nodes[1:]):
if forward:
graph.add_edge(a, b, way=way_id)
if backward:
graph.add_edge(b, a, way=way_id)
return graph
def connectivity(graph: nx.DiGraph, findings: Findings,
min_component: int = 20) -> None:
"""A healthy car network is nearly one giant strongly connected component.
Everything outside it is a genuine dead end or a directional bug, and the
two are distinguished by whether the pocket has an undirected connection
to the giant component that the directions have closed off.
"""
components = sorted(nx.strongly_connected_components(graph),
key=len, reverse=True)
if not components:
return
giant = components[0]
logger.info("giant component holds %.2f%% of nodes",
100 * len(giant) / graph.number_of_nodes())
undirected = graph.to_undirected(as_view=True)
for component in components[1:]:
if len(component) < min_component:
continue
touches_giant = any(neighbour in giant
for node in component
for neighbour in undirected.neighbors(node))
if not touches_giant:
continue
# Physically connected, directionally isolated: a oneway bug.
into = any(graph.has_edge(n, m) for n in giant for m in component
if graph.has_edge(n, m))
(findings.inescapable if into else findings.unreachable).extend(
sorted(component)[:5])
if __name__ == "__main__":
logger.info("validate against the profile the router actually uses")
Step-by-step walkthrough Jump to heading
- Take the profile as a parameter. The same data is correct for one profile and broken for another, so a check without a profile is answering an undefined question.
- Never guess an unparseable value. Defaulting
oneway=1to bidirectional invents a route; defaulting it to oneway removes one. Report it and leave the graph unchanged. - Apply access keys in precedence order.
motorcaroverridesmotor_vehicleoverridesvehicleoverridesaccess, and a check that ignores that reports a private-access finding on every service road. - Build the directed graph the router would build. The whole point is to find what the router will do, and any divergence in the graph construction makes the findings advisory rather than real.
- Compute strongly connected components. For a car profile the giant component should hold almost everything, and a component of twenty nodes sitting outside it is a strong signal.
- Distinguish unreachable from inescapable. A pocket you can enter but not leave and one you can leave but not enter are different bugs with the same connectivity signature, and naming which is which halves the diagnosis.
- Filter tiny components. Genuine cul-de-sacs, service yards and driveways produce small components legitimately, and a floor of around twenty nodes removes most of that noise.
- Check physical adjacency before reporting. A component with no undirected connection to the giant one is simply a disconnected area, not a directional bug.
Verification Jump to heading
- A synthetic reversal is caught. Flip one
onewayin a test grid and confirm the component analysis finds the pocket. - The giant component dominates. For a car profile on a city extract, it should hold well over ninety-nine percent of nodes.
- Profiles differ. Run car and foot profiles over the same data and confirm the foot graph has far fewer components.
- Unparseable values are reported, not absorbed. Introduce
oneway=trueand confirm it appears as a finding. - Precedence works. Tag a way
access=nowithmotorcar=yesand confirm the car profile treats it as open.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Router refuses plausible destinations | Reversed oneway creating a pocket | Report small components adjacent to the giant one |
| Every service road flagged as private | Access keys applied without precedence | Evaluate general to specific, most specific wins |
| Findings disagree with the router | Check builds a different graph | Share the profile rules with the router |
| Unusual oneway values silently ignored | Unrecognised value defaulted | Report unparseable values rather than guessing |
| Thousands of tiny components reported | No minimum component size | Filter below about twenty nodes |
| Disconnected islands reported as bugs | Physical adjacency never checked | Require an undirected link to the giant component |
| Cyclists routed the wrong way | oneway:bicycle not consulted |
Let the profile name its own exception key |
Specification reference Jump to heading
The
onewaykey takesyes,noor-1, where-1indicates the restriction applies against the way’s drawn direction;true,falseand1are documented as deprecated equivalents. Transport-mode access keys form a hierarchy in which a more specific key overrides a more general one for the modes it covers, somotorcaroverridesmotor_vehicle, which overridesvehicle, which overridesaccess. Mode-specific oneway exceptions take the formoneway:<mode>. See the OpenStreetMap wiki pages foroneway, conditional restrictions and access.
Frequently Asked Questions Jump to heading
How do you tell a genuine dead end from a directional bug?
By whether the component is physically adjacent to the rest of the network in the undirected graph. A cul-de-sac is its own small component only if it is oneway inward or outward, which is unusual; normally it is part of the giant component because you can drive both ways along it. A component that touches the giant one undirectedly but not directionally is almost always a tagging error, and that adjacency test removes nearly all the false positives.
Should the validator fix what it finds?
No, and especially not by reversing tags automatically. Some of these are genuine: a street really can be oneway in a way that makes a block awkward to reach, and mappers survey the ground. The validator’s output belongs in a review queue where somebody can check imagery or local knowledge, and an automated fix pushed upstream is how a corrective edit becomes a mapping dispute.
What about conditional restrictions?
oneway:conditional and time-based access are real and widely used, and a validator that ignores them will report findings on correctly tagged data. The pragmatic approach is to evaluate them for a representative time — a weekday midday — and to mark findings on conditionally restricted ways as lower confidence, since the check has picked one moment out of a schedule. Treating them as unconditional is the alternative, and it produces confident nonsense.
Does this need the whole network, or can it run on an extract?
The connectivity analysis needs enough of the network that the giant component is genuinely giant, which an extract clipped to a city boundary does not give you — every road crossing the boundary becomes a false dead end. Running it on an extract with a generous buffer, and ignoring findings within that buffer, is the usual compromise. The clipping strategies in the extract material apply directly.
Related Jump to heading
- Routing Graph Topology QA — the parent topic.
- Continuous QA for OSM Pipelines — running this as a semantic check in the gate.
- Normalizing OSM Yes/No Tag Values — the variants this check reports.
- Finding Statistical Outliers in OSM Tag Values — the complementary tag-level approach.
- OSM Extract Clipping & Boundaries — why an extract’s edge creates false dead ends.
Up one level: Routing Graph Topology QA.