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.

Where the conversion cost is repaid igraph carries a fixed conversion cost of about six seconds on a million-edge graph, after which each query is roughly twenty times cheaper than NetworkX. The two lines cross at about three hundred queries. Total wall clock against query count, 1 M-edge metro graph 0 300 1 000 3 000 queries time NetworkX — 95 ms per query igraph — 6 s conversion, then 4 ms per query break-even ≈ 300 Below the crossover the conversion dominates; above it the query cost does, and the gap widens without limit. A dispatch run computing a 200 × 200 matrix issues 200 single-source searches, which sits comfortably to the right.

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.

Three ways a conversion loses data quietly Collapsed parallel edges lose the alternative between two junctions. A rebuilt id mapping makes every returned index refer to a different node. An undirected default turns every one-way street into a two-way one. None raises an error. All three produce a working graph that answers some queries wrongly parallel edges collapsed the alternative way is gone edge count silently drops mapping rebuilt later index 4 meant node 25471883 now it means node 31002118 every result resolves — to the wrong place directed left at default every one-way becomes two-way routes look shorter and are illegal A single assertion — edge counts equal in both directions, plus a spot check that a one-way is still one-way — catches all three. Run it on every conversion rather than once during development; the defaults are easy to reintroduce. Attribute transfer decides whether the copy is compact An igraph copy carrying only weights holds 410 megabytes. Carrying every NetworkX edge attribute holds 2.9 gigabytes, which exceeds the NetworkX graph it was meant to replace. Memory held by the igraph copy, by attributes transferred weight only 410 MB + edge id 470 MB + name and surface 1.3 GB every attribute 2.9 GB — larger than the source If a query never reads an attribute, that attribute has no business being in the query engine — join it back onto results afterwards instead. Keep one identifier so results can be joined; everything beyond that is weight the fast path has to carry for nothing.

Validation checklist

  • Edge counts match. With keep_parallel=True, g.ecount() must equal G.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.