NetworkX vs. igraph for Large Indoor Routing Graphs

Choosing the graph library underneath an indoor router is a decision that only bites at scale, which is why it belongs in the Fallback Routing Architectures reference: a single building routes fine on anything, but a campus graph with hundreds of thousands of edges makes the NetworkX-versus-igraph question the difference between a five-millisecond route and a stalled kiosk.

What the Choice Actually Trades

Both libraries model the same thing — a weighted graph you run shortest paths over — but they sit at opposite ends of the ergonomics-versus-performance axis, and the right pick depends entirely on where your graph falls on the size curve.

NetworkX is pure Python. A node is any hashable object, an edge is a dictionary of attributes, and the whole graph is nested dicts. That makes it a joy to author against: node IDs are your own string keys like "L2-room-0184", attributes are read and written inline, and the API reads like the domain. The cost is that every node and edge is a Python object with per-object memory overhead and interpreter-speed traversal, so a graph of a few hundred thousand edges consumes gigabytes and runs Dijkstra in tenths of a second rather than milliseconds.

igraph wraps a C core. Vertices are integers 0..n-1, edges are stored in compact C arrays, and shortest-path search runs entirely in compiled code. At campus scale it is roughly an order of magnitude faster and several times smaller in memory. The cost is the integer-index model: igraph does not route on your string IDs, so you maintain a bidirectional mapping between your domain node IDs and igraph’s dense integer indices, and every attribute access crosses the Python/C boundary. The API is less forgiving, and a stale index mapping is a class of bug NetworkX simply does not have.

The practical rule: author and validate on NetworkX for its ergonomics, and switch the hot routing path to igraph once the graph outgrows single-building scale. The topology is identical either way — only the container changes.

Minimal Working Example

The example builds the same tiny four-node routing graph in both libraries and runs one shortest path, so the index-model difference is visible side by side. Note that NetworkX returns your string IDs directly, while igraph returns integer indices you must map back.

import logging
from typing import Optional

import networkx as nx
import igraph as ig

logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
logger = logging.getLogger(__name__)

EDGES: list[tuple[str, str, float]] = [
    ("lobby", "hall_a", 5.0), ("hall_a", "clinic", 12.0),
    ("lobby", "hall_b", 4.0), ("hall_b", "clinic", 18.0),
]


def route_networkx(src: str, dst: str) -> list[str]:
    """Shortest path on NetworkX: node IDs are your own strings, returned directly."""
    g = nx.Graph()
    for u, v, w in EDGES:
        g.add_edge(u, v, weight=w)
    try:
        return nx.dijkstra_path(g, src, dst, weight="weight")
    except nx.NodeNotFound:
        logger.error("networkx: %s or %s not in graph", src, dst)
        return []


def route_igraph(src: str, dst: str) -> list[str]:
    """Shortest path on igraph: vertices are integers, so map string IDs to indices and back."""
    names = sorted({n for e in EDGES for n in e[:2]})
    idx = {name: i for i, name in enumerate(names)}           # domain id -> integer vertex
    g = ig.Graph(n=len(names))
    g.add_edges([(idx[u], idx[v]) for u, v, _ in EDGES])
    g.es["weight"] = [w for *_, w in EDGES]
    result = g.get_shortest_paths(idx[src], to=idx[dst], weights="weight", output="vpath")
    if not result or not result[0]:
        logger.warning("igraph: no path %s -> %s", src, dst)
        return []
    return [names[i] for i in result[0]]                      # integer vertices -> domain ids


logger.info("networkx: %s", route_networkx("lobby", "clinic"))
logger.info("igraph:   %s", route_igraph("lobby", "clinic"))

Both print ['lobby', 'hall_a', 'clinic']. The nine extra lines in route_igraph are the whole story: the idx map and the closing [names[i] ...] translation are permanent overhead you carry forever with igraph, in exchange for compiled-speed search once the graph is large.

NetworkX vs. igraph Reference

Figures are indicative orders of magnitude for a connected campus routing graph on commodity hardware; benchmark your own topology before committing.

Dimension NetworkX igraph
Core implementation Pure Python (nested dicts) C library with a Python wrapper
Vertex identity Any hashable (your string IDs) Dense integers 0..n-1; you map IDs yourself
Shortest-path speed (100k+ edges) Slower; interpreter-bound, tenths of a second ~5-20× faster; compiled, low single-digit ms
Memory per node/edge High; every element is a Python object Low; compact C arrays, several times smaller
Build / load time Slower; per-edge Python calls Faster bulk add_edges; slower one-at-a-time
Attribute handling Inline dicts, ergonomic, cheap to read Parallel attribute arrays; boundary-crossing cost
API ergonomics Rich, Pythonic, huge algorithm set Terser, index-centric, steeper learning curve
Campus-scale suitability Fine to ~10k nodes; strains past ~100k edges Preferred for large multi-building campuses
Best fit Authoring, validation, single buildings The hot routing path at scale

Common Errors & Fixes

igraph returns a path of unfamiliar integers, or IndexError on lookup. You forgot that igraph routes on vertex indices, not your node IDs, and either used a string where an integer was required or indexed your name list with the wrong array. Keep one canonical, stable name -> index map built once when the graph is constructed, and translate at both ends — never rebuild the map per query or the indices can shift:

# WRONG: passing a domain string straight into igraph.
# g.get_shortest_paths("lobby", to="clinic")   # -> error or wrong vertex
# RIGHT: translate through the canonical map at both boundaries.
vpath = g.get_shortest_paths(idx["lobby"], to=idx["clinic"], weights="weight")[0]
route = [names[i] for i in vpath]

NetworkX process memory balloons past 100k edges. Every node object, edge, and attribute dict carries Python overhead, so a large campus graph exhausts RAM long before it is slow. Do not paper over it by trimming attributes; move the hot path to igraph, or keep NetworkX only for the build-and-validate stage and export a compact edge list into igraph for serving. Rebuild the igraph copy only when the topology_hash changes so the two never disagree.

Attribute lookups dominate igraph runtime. Reading g.vs[i]["floor_level"] inside a tight per-edge loop crosses the Python/C boundary on every access and can erase igraph’s speed advantage. Pull attributes out into a plain Python list or NumPy array once (levels = g.vs["floor_level"]) and index that in the loop, so the boundary is crossed once rather than per iteration.

Integration Point

The library choice is not cosmetic — it sets the performance envelope of the whole routing engine and therefore of the Fallback Routing Architectures that wrap it. A fallback pipeline that copies the graph per request (graph.copy() for a vertical-transition reweight) is cheap on a compact igraph and expensive on a heavyweight NetworkX object, which changes how aggressively you can afford to reweight. The same tension appears in Accessible & Multi-Profile Indoor Routing: running N profile weight functions over one shared graph is only affordable if that graph is compact at campus scale, which pushes the serving path toward igraph while authoring stays on NetworkX. A pragmatic split — validate on NetworkX, serve on igraph, keyed by topology_hash — is compatible with the graceful-degradation design because both copies derive from one authored topology.

Frequently Asked Questions

At what graph size should I switch from NetworkX to igraph?

There is no universal threshold, but a useful heuristic is node count and query rate: below about ten thousand nodes with occasional routing, NetworkX is comfortable and its ergonomics are worth keeping. Past roughly a hundred thousand edges, or when you route many times per second on an edge kiosk, NetworkX’s per-object overhead and interpreter-speed traversal start to show as latency and memory pressure, and igraph’s compiled core pays for its clunkier API. Benchmark your own topology at your real query rate before committing — the crossover depends heavily on how dense your graph is and how often you reweight.

Can I keep NetworkX for authoring and igraph only for serving?

Yes, and it is the pattern most large deployments converge on. Author, mutate, and validate the graph in NetworkX where the API is pleasant, then export a compact edge list and attribute arrays into igraph for the hot routing path, rebuilding the igraph copy only when the topology_hash changes. Keep one canonical node-ID-to-index map so both representations agree on identity, and treat the NetworkX graph as the source of truth and the igraph copy as a derived, disposable serving index.

Does igraph support the weight-function style used for accessibility profiles?

Not the same way. NetworkX accepts a Python callable w(u, v, attrs) and can therefore exclude an edge by returning None at query time. igraph expects a precomputed weight vector over its edges, so you materialise one weight array per profile — set excluded edges to a very large value or, better, delete them from a per-profile subgraph — rather than deciding lazily during traversal. The result is identical, but the exclusion moves from the search loop into a build step that you cache per profile.

This page is part of the Fallback Routing Architectures guide within the Indoor Mapping Architecture & Standards reference.