We have a 171-package Python monorepo. It’s been running in production for years. We use its dependency graph on every single PR — it drives test selection, deciding which of our ~15k tests need to run when you change a file. The graph infrastructure is load-bearing.
And nobody had ever actually looked at it.
The MRI machine you never turned on
Here’s the situation. Every pyproject.toml in the repo declares edges — “I depend on X, Y, Z.” Our lockfile resolves the transitive closure. Our CI system, workspace_graph.py, walks that graph to figure out what changed and what tests to run. It answers exactly one question: “did this change affect that package?”
That’s an MRI machine you’re using as a nightlight.
The graph already exists. The data is already parsed. Nobody had ever asked: what does the shape of this graph look like? What’s the longest dependency chain? Which packages are hubs? Which ones are dead leaves that nothing depends on? Could we split this monorepo into two?
So I built the viewer. It was embarrassingly easy.
Twenty lines of Python
The monorepo already has ci.workspace_graph, which parses all 171 packages and their internal dependencies. Converting that to a networkx graph is barely a script (simplified — the real function takes a few more arguments):
from ci.workspace_graph import build_distributions_from_worktree
import networkx as nx
_, workspace_distributions = build_distributions_from_worktree(repo_root, config, env)
G = nx.DiGraph()
for name, dist in workspace_distributions.items():
for dep in dist.dependencies:
if isinstance(dep, WorkspaceDistribution):
G.add_edge(name.canonical, dep.name.canonical)
That’s it. You now have a directed graph of your entire monorepo — acyclic if nobody’s introduced circular dependencies, which we also check for. Every package is a node. Every internal dependency is an edge. networkx gives you the entire graph theory toolkit for free.
The hard part was already done — someone built the parser years ago for CI. I just plugged in a different output.
The hairball
First thing you do with a graph: draw it.
Don’t do that.
Or rather — do it once, because the reaction is useful. You get a hairball. A tangle of edges so dense it looks like you dropped a plate of spaghetti on your monitor. The visualization is useless for understanding, but it’s very useful for convincing people that “yeah, we should probably analyze this properly.”
The hairball is not the analysis. The hairball is the pitch deck.
What the numbers actually tell you
Once you stop trying to look at the picture and start querying the topology, things get interesting fast.
Fan-in — packages that everything depends on. in_degree in graph terms. Our top hitters: mai_config, mai_nano, common_utils, lib/blobfile. These are your structural load-bearing walls. A breaking change in mai_config cascades to practically every package in the repo. These are the ones where people say “I just added a one-line change and 200 tests ran.” Fan-in is why — you touched a hub, and the shockwave propagated outward through every reverse dependency.
Fan-out — packages that depend on everything else. High out_degree. These are fragile by construction — any change to any of their deps could break them. They’re the packages whose CI is always flaking, always slow, always pulling in half the universe. Not because they cause cascades, but because they receive them. Twenty dependencies means twenty chances for someone else’s change to ruin your build.
Leaves — packages with zero in-degree. Nothing depends on them. In a monorepo, leaves are one of three things: applications (they should be leaves), experiments that got promoted to “permanent” without anyone noticing, or dead code. We estimated 15-20 of these. Worth auditing. A leaf package that isn’t an app is just weight.
Longest path — dag_longest_path. The longest chain of sequential dependencies in the graph. Estimated 6-8 hops for ours. This is your theoretical minimum build depth — even with infinite parallelism, you can’t build faster than this critical path. Every hop on the longest chain is a serialization point.
Connected components — could this monorepo be two monorepos? If you have disconnected subgraphs, those are natural split points. In practice, a giant monorepo usually has one dominant component with a few tiny satellites. But knowing that is different from assuming it.
The centrality rabbit hole
Once you have networkx, you can go deeper than degrees.
PageRank tells you which packages are structurally important — not just “many things depend on me” but “many important things depend on me.” It’s the same algorithm Google used to rank web pages, and it turns out your monorepo has the same topology as the internet. Some packages are Google. Some are someone’s abandoned Geocities page.
Betweenness centrality tells you which packages sit on the most shortest paths between other packages. High betweenness = bottleneck. If that package’s build is slow, it slows everything that transits through it. These are your CI chokepoints — the packages where caching matters most.
You don’t need all of this. But the point is: once you have the graph in networkx, every question is a one-liner. nx.pagerank(G). nx.betweenness_centrality(G). nx.dag_longest_path(G). The analysis is free. The data was always there. You just never asked.
The CI connection
Here’s why this matters beyond academic interest.
We already use this graph for test selection. On every PR, workspace_graph.py walks the dependency edges to figure out which packages are affected by a change, then runs only those tests. It’s fast and correct. But the graph can answer much harder questions:
Impact radius. Before you refactor mai_config, you can compute exactly how many packages (and tests) will be affected. Not a guess — a number. len(nx.ancestors(G, 'mai_config')).
Migration planning. We’re migrating packages to Bazel. The graph tells you which packages are “ready” — all their deps are already migrated. No manual tracking spreadsheet. Just ready = [n for n in G if all(d in migrated for d in G.successors(n))].
Dead dependency detection. A declared dependency that no code actually imports is invisible to the source-level graph but still triggers unnecessary test runs. Cross-referencing the graph with actual imports finds phantom edges.
The graph isn’t just a CI optimization. It’s a map of your codebase’s structure. And you can’t navigate without a map.
The migration planner nobody planned
Here’s where it gets circular.
We want the dependency graph to tell us which packages to migrate to Bazel first. But Bazel’s nice query tooling — bazel query, rdeps, somepath — only works after a package is migrated. So you need to analyze the pre-Bazel graph to plan the migration to Bazel.
The networkx script doesn’t just analyze the monorepo. It is the migration planner. The tool builds the road it eventually gets replaced by.
Why you can’t just migrate everything at once
Bazel migration is bottom-up by construction. If package A depends on package B, you can’t write a working BUILD file for A until B is already on Bazel. Foundations first, then upward. This means waves — you migrate the leaves, then the things that only depended on leaves, then the next layer, and so on.
The graph gives you the waves for free:
poison_pills = {"lib-bus", "lib-chz", "fasttext", "mai-kernels"}
migrated = set(already_migrated) # 26 packages
wave = 0
while True:
ready = [p for p in G if p not in migrated
and all(d in migrated for d in G.successors(p))
and not any(is_blocked_by(p, pill) for pill in poison_pills)]
if not ready:
break
migrated.update(ready)
wave += 1
Each wave unlocks the next. You run the script, migrate that batch, run it again. The Bazel-queryable portion of the repo grows, and the networkx script’s job shrinks. Gradually making itself obsolete.
The ceiling
But some packages don’t show up in any wave. Not because they’re deep in the graph — because they’re blocked by problems that aren’t “migrate it next” but “we need to solve C++ toolchains in Bazel first.” Poison pills.
lib/bus— C++ and Cython. Blocks ~28 downstream packages. This is the big one. Everything flows throughlib/bus → lib/oai_api → common_utils → half the repo.lib/chz— metaprogramming so aggressive it breaks gazelle (Bazel’s Python dependency auto-detector). 5 packages stuck behind it.mai_kernels— CUDA compilation. Overlaps heavily with thelib/bussubtree.torch— was the biggest blocker. System-installed, not inuv.lock, invisible to Bazel’s hermetic worldview. 22 packages directly blocked, ~45 transitively.
That last one we cracked. The fix was a system Python toolchain in Docker — register the container’s Python (which has torch installed) as a Bazel toolchain, and suddenly Bazel can see system packages. One poison pill dissolved, 30 packages unlocked in a single PR. The graph told us the blast radius before we wrote the fix.
The remaining ceiling is lib/bus. The graph says: everything not transitively blocked by a poison pill is migratable today. Everything else waits until someone solves the hard C++/CUDA toolchain problem. No spreadsheet required — just nx.ancestors(G, 'lib-bus') and there’s your blocklist.
The Bazel punchline
Here’s the thing that kills me a little.
Everything I just described — the fan-in analysis, the impact radius, the leaf detection, the longest path — all of that is approximately three lines of Python on top of an existing CI system that was never designed for it.
Once we finish migrating to Bazel, it’s zero lines. It’s a shell command:
bazel query 'rdeps(//..., //mai_config)' --output=graph | dot -Tsvg
That gives you every package that transitively depends on mai_config, rendered as a graph. Want the longest chain? --output=maxrank. Want to know if two packages are connected? somepath(//A, //B). Bazel’s query language can express it.
The build system is the graph database. That’s the whole pitch for Bazel in a monorepo, honestly. Not “it’s faster” (it is, but that’s a side effect). The pitch is: your build system understands the shape of your code. Every analysis you’d write a custom script for, Bazel already has as a query primitive.
We’re at 26 out of 171 packages migrated. When we get to 171, the twenty lines of networkx I wrote become a historical curiosity. The MRI machine gets a proper interface.
Until then — networkx and a Friday afternoon got us 80% of the way there. Sometimes the data is already in the room. You just have to ask it a question.