OSMnx Graph Conversion Techniques Jump to heading

Turning raw OpenStreetMap ways into a deterministic, topology-validated networkx.MultiDiGraph is the step where a normalization pipeline either earns or loses the trust of every routing engine downstream. OSMnx is the most accessible abstraction for this conversion, but its defaults are tuned for exploratory notebooks, not production spatial ETL: it retains permissive tags, preserves every degree-2 node, leaves maxspeed as free-text strings, and keeps disconnected subgraphs that quietly break shortest-path queries. The concrete failure scenario is familiar — an A* call returns NetworkXNoPath for two points that are obviously connected on the map, because the source node landed in a 3-edge island that survived extraction, or travel times come back as inf because "50;30" never parsed to a float. This page shows how to convert OSM ways into routing-ready graphs that behave identically across reruns, regions, and machines.

OSMnx Graph Conversion Data Flow A six-stage pipeline arranged as a snake. Top row left to right: cleaned OSM ways as a GeoDataFrame, ox.graph_from_bbox or graph_from_gdfs, and ox.simplify_graph which merges degree-2 nodes. The flow drops down the right side into the bottom row, which runs right to left: ox.project_graph reprojecting EPSG:4326 to the UTM zone, edge weights computing length and travel_time, and finally A-star or Dijkstra routing. Cleaned OSM ways (GeoDataFrame) ox.graph_from_bbox / graph_from_gdfs ox.simplify_graph merge degree-2 nodes ox.project_graph EPSG:4326 → UTM zone Edge weights length · travel_time A* / Dijkstra routing

Prerequisite concepts Jump to heading

Graph conversion assumes the upstream stages of the Parsing & Tag Normalization Workflows pipeline have already produced clean, typed input. Three foundations in particular must be in place first. You need a working grasp of the Node-Way-Relation Data Model, because OSMnx builds nodes from OSM nodes and edges from the ordered node references inside each way — the topology you get out is only as good as the way membership you feed in. You need the controlled vocabularies described in Tag Taxonomy & Key-Value Standards, since the highway, maxspeed, oneway, and surface keys drive every routing weight. And because edge length is meaningless in degrees, you must understand projection from the work on Coordinate Reference Systems in OSM before any distance- or time-based weight is computed.

Specification & format reference Jump to heading

OSMnx encodes a street network as a directed multigraph whose attribute schema is fixed enough to validate against. The fields that matter for routing are:

Attribute Lives on Type after normalization Source OSM key Routing role
x, y node float (CRS units) node lon/lat geometry, snapping
osmid node / edge int or list[int] element id provenance
length edge float (metres) computed from geometry distance weight
highway edge str highway class, speed lookup
maxspeed edge float (km/h) maxspeed speed input
speed_kph edge float imputed travel-time input
travel_time edge float (seconds) computed time weight
oneway edge int (-1/0/1) oneway directionality
reversed edge bool derived edge orientation

Two encoding rules trip up most pipelines. First, OSMnx stores parallel edges between the same node pair under integer key values (0, 1, 2 …) — this is why iteration must use G.edges(keys=True); ignoring the key collapses dual carriageways and turn lanes. Second, length is computed in the graph’s current CRS, so a length derived before projection is in degrees and silently wrong. Project first, then compute or trust length.

Travel time per edge is derived from length (metres) and maxspeed (km/h):

tedge=lengthmmaxspeedkm/h×10003600  =  lengthmmaxspeedkm/h×0.2778 secondst_{\text{edge}} = \frac{\text{length}_\text{m}}{\text{maxspeed}_{\text{km/h}} \times \tfrac{1000}{3600}} \;=\; \frac{\text{length}_\text{m}}{\text{maxspeed}_{\text{km/h}} \times 0.2778}\ \text{seconds}

Step-by-step implementation Jump to heading

1. Memory-aware extraction with explicit filters Jump to heading

OSMnx defaults to permissive tag retention and full topology preservation. Production pipelines must initialize extraction with an explicit custom_filter to restrict edge creation to routing-relevant OSM keys, and disable retain_all so disconnected subgraphs are pruned during extraction — reducing both downstream QA overhead and peak memory.

python
import logging
import networkx as nx
import numpy as np
import osmnx as ox

logger = logging.getLogger(__name__)

# Overpass-style filter accepted by osmnx.
CUSTOM_FILTER = (
    '["highway"~"motorway|trunk|primary|secondary|tertiary|residential|'
    'unclassified|service|living_street|track"]'
)


def load_routing_graph(bbox: tuple[float, float, float, float]) -> nx.MultiDiGraph:
    """Extract, filter, and validate an OSMnx graph from a bounding box.

    bbox format for osmnx >= 2.0: (left, bottom, right, top) i.e.
    (min_lon, min_lat, max_lon, max_lat). Check your osmnx version;
    earlier versions used (north, south, east, west).
    """
    try:
        G = ox.graph_from_bbox(
            bbox=bbox,
            custom_filter=CUSTOM_FILTER,
            simplify=True,
            retain_all=False,
        )
        logger.info("Extracted %d nodes, %d edges.", G.number_of_nodes(), G.number_of_edges())
        return G
    except Exception as e:
        logger.error("Graph extraction failed for bbox %s: %s", bbox, e)
        raise

When the upstream stage has already produced GeoDataFrames — for example via Async PBF Parsing with Pyrosm — skip the Overpass round-trip entirely and feed cleaned nodes and edges straight into ox.graph_from_gdfs(gdf_nodes, gdf_edges). This decouples ingestion from the OSM API, removes rate-limit exposure, and lets you tile regions in parallel.

2. Project before you weight Jump to heading

Projection must occur immediately after extraction to ensure accurate edge length calculations. Use ox.project_graph(G) to auto-detect the optimal UTM zone, or ox.project_graph(G, to_crs="EPSG:32633") to pin a fixed zone so every regional tile lands in the same CRS before merging.

python
def project_and_weight(G: nx.MultiDiGraph, to_crs: str | None = None) -> nx.MultiDiGraph:
    """Project to a metric CRS, then impute speeds and travel times."""
    G = ox.project_graph(G, to_crs=to_crs) if to_crs else ox.project_graph(G)
    G = ox.add_edge_speeds(G)        # imputes speed_kph from highway-class table
    G = ox.add_edge_travel_times(G)  # length / speed_kph -> travel_time (seconds)
    return G

ox.add_edge_speeds(G) imputes missing maxspeed from highway-class lookup tables and writes speed_kph; ox.add_edge_travel_times(G) then computes travel_time in seconds. Pin the UTM zone explicitly for any multi-region run so two adjacent tiles never get auto-assigned to different zones.

3. Deterministic tag normalization Jump to heading

OSMnx preserves raw OSM tags as string attributes. Before any weight is computed, those strings must be coerced deterministically — the same logic explored in depth in Value Standardization & Regex Cleaning, applied here at the edge level.

python
import re


def normalize_edge_attributes(G: nx.MultiDiGraph) -> nx.MultiDiGraph:
    """Apply regex cleaning, type coercion, and fallback imputation to edge data."""
    speed_pattern = re.compile(r"(\d+(?:\.\d+)?)\s*(?:km/h|kmh|kph)?", re.IGNORECASE)
    oneway_map = {"yes": 1, "true": 1, "1": 1, "no": 0, "false": 0, "0": 0, "-1": -1}

    for u, v, k, data in G.edges(data=True, keys=True):
        # Normalize maxspeed — OSM stores values like "50", "30 mph", "50;30".
        raw_speed = data.get("maxspeed")
        if isinstance(raw_speed, list):          # tag conflict: take the minimum
            raw_speed = min(raw_speed, key=lambda s: float(speed_pattern.search(str(s)).group(1)))
        if raw_speed and isinstance(raw_speed, str):
            match = speed_pattern.search(raw_speed)
            data["maxspeed"] = float(match.group(1)) if match else np.nan
        elif not isinstance(raw_speed, (int, float)):
            data["maxspeed"] = np.nan

        # Normalize oneway
        raw_oneway = str(data.get("oneway", "no")).lower()
        data["oneway"] = oneway_map.get(raw_oneway, 0)

        # Standardize surface class
        surface = str(data.get("surface", "unknown")).lower()
        if surface in ("paved", "asphalt", "concrete", "sett", "cobblestone"):
            data["surface_class"] = "paved"
        elif surface in ("unpaved", "gravel", "dirt", "sand", "grass"):
            data["surface_class"] = "unpaved"
        else:
            data["surface_class"] = "unknown"

    return G

The list branch is the subtle one: when a way carries multiple maxspeed values (e.g. ["50", "30"]), OSMnx hands you a Python list, not a string, and a naive .search() raises TypeError. Resolving conservatively to the minimum keeps routing safe. For authoritative tagging semantics, consult the OpenStreetMap Wiki Map Features.

4. Topology and attribute validation gates Jump to heading

Large extracts frequently carry malformed geometries, missing mandatory tags, or topological inconsistencies that cause silent failures downstream.

python
def validate_graph_topology(G: nx.MultiDiGraph) -> nx.MultiDiGraph:
    """Enforce routing-ready topology and attribute completeness.

    Returns the (possibly pruned) graph. Raises ``ValueError`` when attribute
    coverage falls below the configured threshold.
    """
    if not nx.is_weakly_connected(G):
        logger.warning("Graph has disconnected components. Pruning isolated subgraphs.")
        largest_cc = max(nx.weakly_connected_components(G), key=len)
        G = G.subgraph(largest_cc).copy()

    missing_speeds = sum(
        1 for _, _, d in G.edges(data=True)
        if np.isnan(float(d.get("maxspeed", float("nan"))))
    )
    coverage_gap = missing_speeds / max(G.number_of_edges(), 1)
    if coverage_gap > 0.35:
        raise ValueError(
            f"Excessive missing maxspeed values ({coverage_gap:.0%}). "
            "Run ox.add_edge_speeds() before validation."
        )

    logger.info("Topology and attribute validation passed.")
    return G

Use deterministic random seeds for any stochastic imputation step so the validation verdict is reproducible across reruns. For broader graph manipulation patterns, refer to the official NetworkX documentation.

Validation & error-handling matrix Jump to heading

Error condition Root cause Detection method Remediation
NetworkXNoPath on connected-looking points source/target in a pruned island nx.is_weakly_connected(G) keep largest weakly-connected component, snap to it
travel_time == inf maxspeed NaN, speed_kph zero scan edges for non-finite weights run ox.add_edge_speeds() then add_edge_travel_times()
TypeError in speed parse maxspeed is a list, not str type-check before regex resolve conflicting tags to the minimum
edge length in degrees weighting ran before projection check G.graph["crs"] is metric call ox.project_graph() first
dual carriageway collapsed iterating without keys=True compare edge count pre/post use G.edges(data=True, keys=True)
Overpass 429 / timeout API rate limiting catch on extraction retry with backoff, or switch to graph_from_gdfs
nondeterministic graphs across runs unseeded imputation / API drift hash node+edge sets seed RNG, pin source extract checksum

Performance & scale considerations Jump to heading

For continental-scale pipelines, preprocessing raw .osm.pbf files before graph construction dramatically improves throughput. Streaming primitives into memory-mapped buffers, filtering at the byte level, and feeding cleaned GeoDataFrames directly into ox.graph_from_gdfs decouples extraction from conversion, reducing peak RAM and enabling parallel regional tiling — the chunking patterns in Memory-Efficient Chunk Processing apply directly here.

Practical scaling levers:

  1. Chunked processing with checkpointing. Divide large bounding boxes into non-overlapping grid cells driven by the tiling scheme in Spatial Indexing for OSM Extracts. Persist each processed tile to disk before merging, and resume from the last successful checkpoint rather than restarting the full extract.
  2. Memory-mapped serialization. Persist projected subgraphs with pickle (protocol 5, with buffer_callback) or serialize edge lists to Parquet so large graphs round-trip without loading the entire structure into contiguous RAM.
  3. Idempotent execution. Hash input extract checksums and graph configuration parameters to cache results and prevent redundant recomputation.

ox.simplify_graph is the single biggest size lever: merging interstitial degree-2 nodes routinely removes 60-80% of nodes on dense urban grids while preserving routing geometry, so simplify before you serialize.

Failure modes & gotchas Jump to heading

  • Auto-UTM drift across tiles. ox.project_graph(G) picks the zone from the graph’s centroid; two adjacent tiles can land in different zones and fail to merge cleanly. Pin to_crs for any multi-tile run.
  • simplify=True after manual edits. Calling ox.simplify_graph twice raises, and editing geometry before simplification can strip attributes you set. Normalize and weight after simplification.
  • Bbox argument order. OSMnx ≥ 2.0 expects (left, bottom, right, top); pre-2.0 code passing (north, south, east, west) silently extracts the wrong region.
  • retain_all=False over-pruning. On sparse rural extracts the “largest component” can discard legitimately reachable hamlets connected only by ferries or tracks excluded by the filter. Audit component sizes before trusting the prune.
  • String weights reaching the solver. If add_edge_travel_times never ran, travel_time is absent and nx.shortest_path(..., weight="travel_time") falls back to hop count, returning plausible-but-wrong routes.

Integration points Jump to heading

The conversion’s output — a projected, normalized, validated MultiDiGraph — is the direct input to routing and isochrone computation. Wire the stages into one idempotent entry point:

python
def build_graph(bbox: tuple[float, float, float, float], crs: str = "EPSG:32633") -> nx.MultiDiGraph:
    """End-to-end: extract -> normalize -> project -> weight -> validate."""
    G = load_routing_graph(bbox)
    G = normalize_edge_attributes(G)
    G = project_and_weight(G, to_crs=crs)
    G = validate_graph_topology(G)
    return G


# Downstream routing consumes travel_time directly.
G = build_graph((11.50, 48.10, 11.62, 48.18))
orig = ox.distance.nearest_nodes(G, X=11.55, Y=48.14)
dest = ox.distance.nearest_nodes(G, X=11.60, Y=48.16)
route = nx.shortest_path(G, orig, dest, weight="travel_time")

Records that fail normalization or validation should not be dropped silently; route them to the same dead-letter discipline used in Error Handling in Large OSM Extracts, and reconcile the canonical attribute names against the registries defined in Batch Attribute Mapping Strategies so the graph’s edge schema matches every other store in the pipeline.

build_graph Lifecycle with Dead-Letter Branch Five numbered process stages run left to right: 1 extract via graph_from_bbox or gdfs, 2 normalize edge tags with regex coercion, 3 project to a pinned UTM CRS, 4 weight edges with speed and travel_time, and 5 validate by keeping the largest weakly-connected component. The pipeline ends in a routing-ready MultiDiGraph output. Dashed branches drop from the normalize and validate stages into a single dead-letter queue at the bottom, capturing records that fail normalization or validation so none are dropped silently. 1 · Extract 2 · Normalize 3 · Project 4 · Weight 5 · Validate Routing graph bbox / gdfs regex tags UTM CRS speed · time largest CC MultiDiGraph Dead-letter queue records that fail conversion fail fail

Going deeper Jump to heading

Frequently Asked Questions Jump to heading

Should I simplify the graph before or after normalizing tags?

Simplify first. ox.simplify_graph merges interstitial degree-2 nodes and consolidates the attributes of the merged edges; running your normalization afterward means you coerce each surviving edge exactly once and avoid re-deriving values that simplification just rewrote.

Why does my A* call raise NetworkXNoPath between two visibly connected points?

One endpoint almost certainly snapped to a node in a small subgraph that retain_all=False would have pruned, or that survived as a disconnected island. Keep only the largest weakly-connected component and re-snap with ox.distance.nearest_nodes against that pruned graph.

When should I use graph_from_gdfs instead of graph_from_bbox?

Use graph_from_gdfs whenever an upstream parser has already produced clean node and edge GeoDataFrames. It skips the Overpass round-trip, removes rate-limit exposure, and lets you tile and parallelize regions, which matters most at country or continental scale.

How do I make graph builds reproducible across machines?

Pin three things: the source extract (hash the .osm.pbf checksum), the projection CRS (pass an explicit to_crs rather than auto-UTM), and any RNG used in imputation (set a fixed seed). With those fixed, the node and edge sets hash identically on every run.

What happens to edges with no maxspeed tag?

ox.add_edge_speeds() imputes speed_kph from a highway-class lookup table, then ox.add_edge_travel_times() derives travel_time. If imputation is skipped, those edges keep NaN speed and produce inf travel time, which silently distorts shortest-path results.

This guide is part of Parsing & Tag Normalization Workflows; return to that overview to follow the data from parsing and normalization through error triage into routing-graph conversion.