The Fabric Semantic Link Developer Experience Challenge gave me a concrete reason to build something I had been meaning to build for a while. The result is a Fabric notebook that extracts every DAX measure from a Power BI semantic model, maps measure-to-measure dependencies into a directed graph, and produces a ranked complexity audit with risk ratings. This post is about what it does, how it works, and why the approach is useful beyond the challenge context.
The problem it solves
If you have worked on a Power BI semantic model that has grown organically over a few years, you already know the problem. Measures reference other measures. Those measures reference other measures. Nobody wrote it down. The person who built the original [Gross Margin Adj YTD] has since moved on, and the name was self-explanatory at the time.
Power BI Desktop shows you a measure’s DAX expression when you click on it. It does not show you which other measures that expression depends on, how deep the dependency chain goes, or whether any measure sits in a circular reference loop that the engine has been quietly working around. Tabular Editor helps, but it still requires manual navigation. There is no built-in view that answers “what are the ten most complex measures in this model, and which ones does everything else depend on?”
That is what this notebook answers.
Getting the measures out
The notebook uses sempy.fabric.list_measures() from Semantic Link to pull every measure from the target model in a single call. It returns a pandas DataFrame with measure name, parent table, DAX expression, visibility, and description per row.
measures_df = fabric.list_measures(dataset=DATASET_NAME, workspace=WORKSPACE_NAME)
Under the hood, Semantic Link connects via the Tabular Object Model (TOM) over the XMLA endpoint. Fabric handles authentication from the notebook’s identity. Two config values is all the setup needed:
WORKSPACE_NAME = None # None = current workspace
DATASET_NAME = "YourSemanticModelName"
Then Run All.
Parsing measure references: three passes
The interesting part is working out which measures each expression actually references. DAX uses square bracket notation for both measures and columns: [Total Revenue] is a measure reference, Sales[Amount] is a column reference. The parser has to distinguish them correctly.
It does this in three passes:
- Strip string literals (
"...") to avoid false positives. A FORMAT call likeFORMAT([Date], "Total Revenue")would otherwise incorrectly register a dependency on[Total Revenue]. - Strip single-line and multi-line comments (
//and/* */). - Extract all
[Name]patterns where the opening bracket is not preceded by a word character, digit, or apostrophe. That lookbehind excludes table-qualified references likeSales[Amount]and'My Table'[Column].
The extracted names are then cross-referenced against the full set of known measure names. Anything that is not a measure name is discarded.
pattern = r"(?<![a-zA-Z0-9_'])\[([^\]]+)\]"
matches = re.findall(pattern, cleaned)
return list({m for m in matches if m in measure_names})
This correctly handles the common edge cases in real models. The one known limitation: measures referenced via SELECTEDMEASURE() or through a disconnected table SWITCH pattern cannot be resolved statically. If your model uses those patterns heavily, some dependencies will be missing from the graph.
Building the graph
Once the parser has run on every expression, the dependencies go into a NetworkX directed graph. Each measure is a node. An edge A -> B means “A’s DAX expression references measure B” — A depends on B.
The graph direction is important. It lets the tool compute:
- In-degree (fan-in): how many measures depend on this one. High fan-in means “hub” measure. Breaking it cascades everywhere.
- Out-degree (fan-out): how many measures this one calls. High fan-out means complex composition.
- Longest path from any node: the transitive dependency depth.
- Cycles: circular reference chains.
From those two properties alone, the next three analyses fall out naturally.
Five analyses
Dead measures. In-degree of zero means no other measure references this one. It might be a top-level report measure used directly in a visual, or it might be genuinely unused. The notebook flags all of them; cross-referencing with report usage is a follow-up step.
Root measures. Out-degree of zero means no dependencies on other measures. These are the foundation: the SUM(Sales[Amount]) base calculations that everything else builds on. Errors in root measures propagate silently through every measure above them.
Circular references. The notebook runs Johnson’s algorithm via nx.simple_cycles() to find every elementary cycle in the graph. In a well-designed model the result is: “No circular dependencies detected.” When it is not, the full chain is printed — A -> B -> C -> A — so you know exactly what to untangle.
Complexity scoring. Each measure gets a weighted composite score across six dimensions:
| Dimension | Weight | Rationale |
|---|---|---|
CALCULATE / CALCULATETABLE count | 3 | Context transitions are the primary source of subtle DAX bugs |
| Max parenthesis nesting depth | 1 | Readability proxy |
Branching (IF / SWITCH) | 2 | Code path count |
Filter functions (FILTER, ALL, ALLEXCEPT, etc.) | 2 | Filter-context manipulation |
| Dependency depth (longest downstream path) | 2 | Transitive error amplification |
| Fan-out (direct measure references) | 1 | Composition width |
The weights are the part most open to debate. I gave CALCULATE the highest weight because context-transition confusion is, in my experience, where DAX models accumulate the most invisible risk. The depth and branching weights reflect that those properties make a measure harder to verify than to write.
Visual dependency DAG. The notebook renders the graph with matplotlib. Node color encodes complexity score on a green-to-red scale. Node size encodes in-degree, so hub measures are physically larger. An optional pyvis interactive version renders inline in the notebook via an iframe: zoomable, draggable, with hover tooltips showing measure name, table, and score.
The audit report
The final step consolidates everything into a single DataFrame sorted by risk rating, then by descending complexity score within each tier.
Risk logic:
- Critical: participates in any circular reference
- High: complexity score ≥ 20
- Medium: score between 10 and 19
- Low: everything else
The summary banner above the table looks like this:
============================================================
DAX DEPENDENCY GRAPH - AUDIT SUMMARY
============================================================
Total measures: 147
Dependency edges: 312
Unreferenced measures: 38
Root measures (no deps): 22
Circular references: 0
High complexity (>=20): 11
Medium complexity (10-19): 29
============================================================
That is the starting point for any refactoring conversation. Eleven measures scoring High is a concrete prioritisation signal: start there, not with the 38 unreferenced ones.
Limitations worth knowing
The parser only resolves measure-to-measure dependencies. Column-level lineage is out of scope. Calculation groups are not modeled as nodes, so models that use them heavily will have gaps. Cross-model references (composite model / DirectQuery to external datasets) are not in scope either.
The “unreferenced” flag does not mean unused. It means not referenced by other measures. A measure used directly in 15 report visuals will still show as unreferenced in this graph, because the tool has no report-level visibility. That cross-reference is worth doing separately with sempy.fabric.list_reports() if you are planning to delete anything.
Getting the notebook
The notebook is on GitHub: github.com/vestergaardj/DDG-DAX-Dependency-Graph. Upload it to any Fabric workspace, set the two configuration values in the Configuration cell, and run all. Everything else is automatic.
It requires semantic-link-sempy, networkx, and matplotlib, all of which come pre-installed in Fabric. pyvis is optional; the static graph still renders without it.



