Handling one-way streets in Python NetworkX requires modeling the road network as a directed graph (nx.DiGraph) where every edge encodes its legal traversal direction explicitly. This is a specific correctness problem within the broader topic of NetworkX shortest path algorithms for logistics: an undirected graph silently routes vehicles the wrong way down one-way streets, producing paths that are operationally invalid regardless of how accurately you’ve calibrated edge weights. The same directional discipline applies across the Python routing engines ecosystem — whether you’re using NetworkX for prototype analysis or feeding a prepared graph into OSRM or Valhalla, oneway tag normalization must happen before any pathfinding call.
When to Use This Approach
Use directed-graph enforcement whenever your network data originates from OpenStreetMap or any source that stores oneway attributes, and at least one of these conditions applies:
- Urban delivery routing where trucks must comply with traffic law — illegal reverse traversal on a one-way produces paths that GPS navigation will immediately re-route, breaking ETA estimates.
- Multi-stop logistics planning where one routing violation cascades into downstream stops arriving out of sequence, triggering service-level penalties.
- Street networks at any scale. Even small neighbourhood graphs in semi-rural areas carry a meaningful fraction of one-way residential streets; skipping directed enforcement is never safe.
- Prototype analysis before handoff to OSRM or Valhalla. Validating directionality in NetworkX first catches tag-normalization bugs before they propagate into a compiled routing engine where debugging is harder.
The approach is unnecessary only when your graph is a pure logical network (warehouse-to-warehouse links, internal conveyor routing) with no concept of traffic direction.
Implementation
The workflow below is self-contained: it ingests an OSM drive network, normalises the oneway tag for any edges that OSMnx has not already resolved, assigns per-edge travel-time weights, and routes with proper error handling. It deliberately skips the setup boilerplate (library installs, area selection) that the NetworkX shortest path algorithms for logistics guide covers in detail.
# requires: networkx>=3.1, osmnx>=1.9, shapely>=2.0
import networkx as nx
import osmnx as ox
import numpy as np
# --- 1. Ingest drive network -------------------------------------------------
# ox.graph_from_place returns a MultiDiGraph. OSMnx auto-parses oneway
# and reverses edges where oneway=-1 during graph construction.
G = ox.graph_from_place("Utrecht, Netherlands", network_type="drive")
# --- 2. Normalise oneway attributes for any custom-ingested edges -----------
# OSM oneway can arrive as str ("yes"/"no"/"-1"), int (1/0), or bool.
# Vectorise over the edge attribute dict to avoid a Python-level loop.
for u, v, data in G.edges(data=True):
raw = data.get("oneway", False)
if isinstance(raw, str):
raw_lower = raw.strip().lower()
# "-1" is handled by OSMnx at load time; guard here for custom data
data["oneway"] = raw_lower in ("yes", "1", "true")
elif isinstance(raw, (int, float)):
data["oneway"] = bool(raw)
# else: already bool, leave untouched
# --- 3. Assert strict DiGraph (defensive) -----------------------------------
# to_directed() on a MultiDiGraph is a no-op; guards against accidental
# undirected conversion during graph merging or serialisation round-trips.
if not isinstance(G, nx.DiGraph):
G = nx.MultiDiGraph(G.to_directed())
# --- 4. Assign direction-aware travel-time weights --------------------------
# ox.add_edge_speeds() imputes missing speed_kph from highway tag defaults.
# ox.add_edge_travel_times() computes travel_time = length / (speed_kph / 3.6).
G = ox.add_edge_speeds(G) # adds float 'speed_kph' to every edge
G = ox.add_edge_travel_times(G) # adds float 'travel_time' (seconds)
# --- 5. Snap coordinates to nearest graph nodes ----------------------------
# nearest_nodes expects (G, X=longitude, Y=latitude).
# return_dist=True lets you detect poor snaps (dist > 50 m is suspicious).
origin_node, origin_dist = ox.distance.nearest_nodes(
G, X=5.1214, Y=52.0907, return_dist=True
)
dest_node, dest_dist = ox.distance.nearest_nodes(
G, X=5.1300, Y=52.0980, return_dist=True
)
if origin_dist > 100 or dest_dist > 100:
raise ValueError(
f"Snap distance too large: origin={origin_dist:.0f}m, dest={dest_dist:.0f}m. "
"Check coordinate CRS or expand graph extent."
)
# --- 6. Route, respecting one-way constraints --------------------------------
try:
route_nodes = nx.dijkstra_path(G, origin_node, dest_node, weight="travel_time")
route_seconds = nx.path_weight(G, route_nodes, weight="travel_time")
print(f"Route: {len(route_nodes)} nodes, {route_seconds / 60:.1f} min")
except nx.NetworkXNoPath:
# Caused by one-way constraints cutting off the subgraph containing dest_node.
# Fallback: find the strongly connected component containing origin and re-snap.
scc = max(nx.strongly_connected_components(G), key=len)
G_scc = G.subgraph(scc).copy()
if dest_node not in G_scc:
dest_node, _ = ox.distance.nearest_nodes(
G_scc, X=5.1300, Y=52.0980, return_dist=True
)
route_nodes = nx.dijkstra_path(G_scc, origin_node, dest_node, weight="travel_time")
print(f"Fallback SCC route: {len(route_nodes)} nodes")
Key implementation notes:
- OSMnx’s
graph_from_place(..., network_type="drive")already invertsoneway=-1edges at load time. The manual normalisation loop is a safety net for edges merged from custom data sources. ox.add_edge_speeds()applies thehwy_speedsdefault table whenmaxspeedis missing. You can override this withhwy_speeds={"residential": 30, "trunk": 90}to match your regional defaults.- On a
MultiDiGraph(OSMnx’s native type),nx.dijkstra_path()requires a single weight key. Passingweight="travel_time"selects the minimum-weight parallel edge automatically — no pre-simplification step needed for routing.
Key Parameters and Tuning
| Parameter | Recommended value | Sensitivity |
|---|---|---|
network_type |
"drive" |
Always use "drive" for vehicle routing; "all" includes footpaths and ignores one-way semantics for pedestrians, corrupting the directed graph |
| Snap distance threshold | ≤ 50 m urban, ≤ 200 m rural | Larger snaps indicate the coordinate is off-network; use return_dist=True and raise an error rather than routing silently from an incorrect node |
hwy_speeds override |
Match regional speed limits | The OSMnx default table is Europe-centric; North American networks should override {"motorway": 105, "trunk": 90, "primary": 70} |
weight key |
"travel_time" |
Prefer time over raw length for logistics — same-distance edges with different speed limits produce wildly different ETAs |
| SCC fallback | Largest strongly connected component | Using the largest SCC guarantees mutual reachability between any pair of nodes in the fallback set |
Integration Points
The directed NetworkX graph you build here connects directly to three downstream stages:
Towards compiled routing engines. Once you’ve verified that oneway normalisation is correct in NetworkX, you can export the same OSM extract and custom speed profiles into OSRM or Valhalla with confidence. OSRM reads oneway directly from the PBF during its preprocessing step, so any tag bugs you catch in NetworkX prototype will also surface in the compiled engine.
Custom cost functions. The travel_time weight set here becomes the baseline for custom cost functions for routing solvers, where you layer truck weight restrictions, curfew windows, and congestion multipliers on top of the directed edge structure.
Isochrone generation. Directed reachability — the set of nodes reachable from an origin within a time budget — is meaningless on an undirected graph. Once your DiGraph is validated, pass it to nx.single_source_dijkstra_path_length() to generate directed isochrone contours, which feed the polygon-generation step in your isochrone mapping pipeline.
Turn restrictions. NetworkX does not model turn restrictions natively. If your logistics network has legally mandated turn bans (common at signalised urban intersections), the next step after directed-edge enforcement is expanding to an edge-based graph where each node represents a (from_edge, to_edge) pair. This is handled by OSRM’s contraction hierarchies and Valhalla’s costing layers rather than NetworkX.
Validation Checklist
Run these checks after building your DiGraph and before deploying any route:
-
Graph type assertion —
assert isinstance(G, nx.DiGraph)passes. A barenx.Graphat this point means directed routing is silently disabled. -
Oneway attribute coverage — Confirm that one-way edges in your area have the
onewayflag set:sum(1 for u, v, d in G.edges(data=True) if d.get("oneway")) / G.number_of_edges(). In dense urban OSM extracts, expect 15–35 % of edges to be flaggedoneway=True. -
Strong connectivity check —
nx.is_strongly_connected(G)should beTruefor a clean urban network. IfFalse, inspect the components withsorted(nx.strongly_connected_components(G), key=len, reverse=True)[:5]to understand whether isolated nodes are data gaps or expected dead-ends. -
Reverse traversal is blocked — For a known one-way street in your extract, confirm that
G.has_edge(v, u)returnsFalsewhileG.has_edge(u, v)returnsTrue. Use OSMnx’sox.plot_graph_route()to visualise a sampled route and eyeball directionality. -
Weight coverage —
sum(1 for u, v, d in G.edges(data=True) if d.get("travel_time") is None)should be0after callingox.add_edge_travel_times(). AnyNoneweight causesdijkstra_pathto treat the edge as unweighted (unit cost), silently distorting route selection. -
No-path rate in production — Log the rate of
nx.NetworkXNoPathexceptions across a batch of origin-destination pairs. A rate above 2 % in a dense urban network indicates topology gaps (disconnected subgraphs or aggressive one-way clustering) that warrant SCC-based graph pruning before the service goes live.
Why does my route cross a one-way street even with DiGraph?
The most common cause is a graph that started as nx.Graph and was converted with to_directed(). This creates two directed edges for every original undirected edge — including both (u, v) and (v, u) for one-way streets. The fix: always ingest the OSM data directly into a DiGraph (via OSMnx’s graph_from_place) rather than converting from an undirected graph after the fact. If you must convert, explicitly remove the reverse edge for every oneway=True entry after conversion.
OSMnx returns a MultiDiGraph — do I need to simplify it first?
Not for nx.dijkstra_path(). The pathfinder on a MultiDiGraph selects the minimum-weight parallel edge automatically when you pass a weight key. You only need ox.simplify_graph() if you are exporting node/edge tables for analysis, building a spatial index, or preparing data for a compiled routing engine that expects simple graphs. Simplification does not affect one-way enforcement.
Coordinate snapping places my origin on a one-way flowing away from my destination
Use ox.distance.nearest_nodes(G, X, Y, return_dist=True) to retrieve both the snapped node and the snap distance. Then call list(G.successors(origin_node)) to inspect which nodes are reachable in one hop. If the immediate successors are all leading away from your destination area, try snapping to the second- or third-nearest node using ox.distance.nearest_nodes on a subgraph with the problematic node removed, or expand your graph extent by 500 m and re-snap.
Related
- NetworkX Dijkstra vs A* for route calculation — algorithm selection criteria when directionality is enforced and heuristics are available
- Custom cost functions for routing solvers — layering truck-class restrictions and time-window penalties onto directed edges
- Integrating custom traffic weights into OSRM — how directed graph validation in NetworkX maps to OSRM’s PBF-based preprocessing
- Generating isochrones with PySAL and GeoPandas — producing directed reachability polygons from a validated DiGraph