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.

DiGraph vs Graph: one-way edge handling Left side shows nx.Graph with a single bidirectional edge between Node A and Node B labelled "traversable both ways — violates one-way rule". Right side shows nx.DiGraph with a single directed arrow from Node A to Node B labelled "enforces oneway=yes". nx.Graph (undirected) nx.DiGraph (directed) Node A Node B both directions violates oneway — routes against traffic illegally Node A Node B oneway=yes enforces legal direction — A→B only, B→A blocked

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 inverts oneway=-1 edges at load time. The manual normalisation loop is a safety net for edges merged from custom data sources.
  • ox.add_edge_speeds() applies the hwy_speeds default table when maxspeed is missing. You can override this with hwy_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. Passing weight="travel_time" selects the minimum-weight parallel edge automatically — no pre-simplification step needed for routing.

The reason network_type="all" corrupts vehicle routing is structural rather than a matter of extra edges. OSMnx builds a pedestrian-permissive graph by adding the reverse direction of one-way ways, because oneway=yes does not apply on foot. Those reverse edges are indistinguishable from legitimate two-way edges once the graph is built.

What network_type does to a one-way street Under the drive network type a way tagged oneway equals yes yields a single directed edge. Under the all network type the reverse direction is also added, because the restriction does not apply on foot, and a vehicle route will then happily traverse it against the flow. Way tagged oneway=yes, built two ways network_type = "drive" u v one directed edge — the restriction survives network_type = "all" u v reverse edge added for pedestrians — vehicles use it too Nothing downstream can tell the added edge from a real two-way street, so the contraflow route looks entirely valid in the response. Audit for it by counting reciprocal edge pairs on ways tagged oneway=yes — the count should be zero on a drive graph. Build separate graphs per mode rather than one permissive graph filtered at query time.

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

Snapping deserves the same scepticism. A silent nearest-node snap turns a coordinate that is genuinely off-network into a route that starts somewhere else entirely, and because the returned geometry is valid the error only surfaces when a driver is sent to the wrong side of a dual carriageway.

Snap distance as a three-way decision Three coordinates sit at 12, 64 and 380 metres from the nearest graph node. The first is a normal urban snap. The second exceeds the urban threshold and warrants a warning. The third is far enough that the coordinate is almost certainly wrong and the request should fail rather than route. Distance from the requested coordinate to the nearest graph node 0 m 50 m 200 m 400 m 12 m 64 m 380 m snap silently inside the urban threshold ordinary kerb offset snap and warn past 50 m in a dense network log the pair for review raise, do not route the coordinate is off-network geocoding failed upstream Pass return_dist=True and act on the distance. Without it every coordinate snaps to something, and the failure is invisible until delivery. Raise the thresholds for rural networks, where legitimate node spacing is genuinely hundreds of metres.

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:

  1. Graph type assertionassert isinstance(G, nx.DiGraph) passes. A bare nx.Graph at this point means directed routing is silently disabled.

  2. Oneway attribute coverage — Confirm that one-way edges in your area have the oneway flag 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 flagged oneway=True.

  3. Strong connectivity checknx.is_strongly_connected(G) should be True for a clean urban network. If False, inspect the components with sorted(nx.strongly_connected_components(G), key=len, reverse=True)[:5] to understand whether isolated nodes are data gaps or expected dead-ends.

  4. Reverse traversal is blocked — For a known one-way street in your extract, confirm that G.has_edge(v, u) returns False while G.has_edge(u, v) returns True. Use OSMnx’s ox.plot_graph_route() to visualise a sampled route and eyeball directionality.

  5. Weight coveragesum(1 for u, v, d in G.edges(data=True) if d.get("travel_time") is None) should be 0 after calling ox.add_edge_travel_times(). Any None weight causes dijkstra_path to treat the edge as unweighted (unit cost), silently distorting route selection.

  6. No-path rate in production — Log the rate of nx.NetworkXNoPath exceptions 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.