NetworkX is the comfortable place to build and inspect a routing graph, and igraph is where the queries should actually run. Moving between them is a short function that has three specific ways of losing data, all of which are silent. This page covers the conversion, as part of comparing Python graph libraries for OSM within Python Routing Engines & Isochrone Mapping.
The three are the id mapping, parallel edges, and attribute ordering. Each produces a graph that loads and answers queries, and each answers some of them wrongly.
When to use this approach
Convert when the same graph will serve many queries. An origin-destination matrix, an isochrone sweep across a city, a night’s worth of dispatch solves — all amortise a multi-second conversion across thousands of queries that run one to two orders of magnitude faster.
Do not convert for interactive single-route serving. There, the conversion cost is the query cost, and a persistent engine such as OSRM is the better answer entirely.
Implementation
Build the mapping deterministically so two conversions of the same graph produce the same indices.
# requires: networkx, igraph (pip install networkx python-igraph)
import networkx as nx
import igraph as ig
def to_igraph(G: nx.MultiDiGraph, weight: str = "travel_time_s",
*, keep_parallel: bool = False):
"""Convert to igraph, returning the graph and both id mappings."""
# Sorted so the mapping is reproducible across runs and machines
nodes = sorted(G.nodes())
to_dense = {osmid: i for i, osmid in enumerate(nodes)}
edges, weights, keys = [], [], []
seen: dict[tuple[int, int], int] = {}
for u, v, k, data in G.edges(keys=True, data=True):
w = float(data[weight])
pair = (to_dense[u], to_dense[v])
if keep_parallel:
edges.append(pair)
weights.append(w)
keys.append((u, v, k))
continue
# Collapse parallel edges to the cheapest — an explicit choice
prior = seen.get(pair)
if prior is None:
seen[pair] = len(edges)
edges.append(pair)
weights.append(w)
keys.append((u, v, k))
elif w < weights[prior]:
weights[prior] = w
keys[prior] = (u, v, k)
g = ig.Graph(n=len(nodes), edges=edges, directed=True)
g.es["weight"] = weights # positional list, aligned with edges
g.es["nx_key"] = keys
return g, to_dense, nodes
keep_parallel being an explicit argument rather than a default is the point. Collapsing parallel edges is usually right for routing — you only ever want the cheapest of two ways between the same junctions — but it is wrong for anything counting edges or attributing results back to specific OSM ways, and a conversion that decides silently will be wrong for one of those callers.
Assigning weights as a positional list rather than per edge matters for both correctness and speed. igraph aligns es["weight"] with the edge list by position, so building the two together guarantees they correspond; assigning attribute by attribute in a loop is both slower and an opportunity for them to drift.
Coming back is the reverse mapping applied to whatever igraph returned:
# requires: igraph (pip install python-igraph)
def path_to_osmids(g, vertex_path: list[int], nodes: list[int]) -> list[int]:
"""igraph vertex indices -> OSM node ids."""
return [nodes[i] for i in vertex_path]
def shortest_path(g, to_dense: dict[int, int], nodes: list[int],
source_osmid: int, target_osmid: int):
"""Query in igraph's index space, return in OSM's."""
s, t = to_dense[source_osmid], to_dense[target_osmid]
paths = g.get_shortest_paths(s, to=t, weights="weight", output="vpath")
if not paths or not paths[0]:
return None
return path_to_osmids(g, paths[0], nodes)
The nodes list is the reverse mapping and has to travel with the graph. Rebuilding it later from a differently ordered node set produces indices that are valid, resolvable, and refer to the wrong places — which is the same failure described in NetworkX vs igraph vs cuGraph for large graphs.
Attribute transfer deserves a decision rather than a default. igraph will happily carry every NetworkX edge attribute across, and on a large OSM graph that means copying name strings, surface values, tag dictionaries and geometry objects into a structure whose entire purpose is to be compact and fast. The result is an igraph object that uses more memory than the NetworkX graph it replaced, which defeats the exercise.
Transfer only what the queries need — typically the weight and an identifier that maps results back. Everything else stays in the NetworkX graph or, better, in a pandas frame indexed by edge id, where it can be joined onto results after the fact. The rule of thumb is that if a query never reads an attribute, that attribute has no business being in the query engine.
Key parameters and tuning
| Parameter | Recommended value | Notes |
|---|---|---|
| Node ordering | sorted(G.nodes()) |
Deterministic across runs; unsorted mappings are irreproducible |
| Parallel edges | explicit argument | Collapse for routing, keep for attribution — never decide silently |
| Collapse rule | cheapest wins | Matches what a router would have chosen anyway |
| Weight assignment | positional list | Aligned by construction, and far faster than per-edge assignment |
| Reverse mapping | stored with the graph | Rebuilding it later is how indices come to mean the wrong nodes |
directed |
always True |
The default is undirected, which silently makes every street two-way |
| Round-trip check | edge counts both ways | The cheapest assertion that catches all three failure modes |
Integration points
Into matrix computation. A converted graph is the natural backend for many-to-many work, where igraph’s single-source calls fill a matrix row at a time. The batching principle is the same one described in chunking large origin-destination matrices, and the per-query saving compounds across every cell.
Into map matching. The transition term in a hidden Markov matcher issues an enormous number of short routing queries, which is exactly the workload igraph is good at — see hidden Markov map matching in Python. Converting once at service start and reusing the graph for every trace is what makes a Python matcher practical.
Back into NetworkX. Results usually go back for inspection or geometry construction rather than the whole graph. Converting a graph back wholesale is rarely necessary and always loses attributes that were never transferred in the first place.
Validation checklist
- Edge counts match. With
keep_parallel=True,g.ecount()must equalG.number_of_edges(). A shortfall means edges were collapsed unintentionally. - The graph is directed. Assert
g.is_directed(). The igraph default is undirected, and an undirected routing graph produces shorter, illegal routes. - A one-way street stays one-way. Query the reverse direction of a known one-way and confirm no path is returned.
- Costs agree between libraries. For a sample of pairs, igraph’s shortest-path cost must match NetworkX’s to floating-point tolerance. A systematic difference points at a weight-alignment error.
- The mapping round-trips. For every node,
nodes[to_dense[osmid]] == osmid. It is a one-line check that makes the whole index translation trustworthy.
Related
- Comparing Python graph libraries for OSM — the benchmark that motivates converting in the first place
- NetworkX vs igraph vs cuGraph for large graphs — the dense-id requirement these libraries share
- Hidden Markov map matching in Python — a workload where the per-query saving is decisive
- NetworkX shortest-path algorithms for logistics — the API this conversion is moving away from, and why