Which DAX measures a converter can't translate: 271 measures checked
The short answer
In 12 Microsoft sample reports (271 measures), searching for the function names Snowflake lists finds 38 (14.0%). Adding 13 that read calculation groups or calculated tables, which Snowflake says are dropped, and 88 that build on a flagged measure, our assumption, gives 139 (51.3%). We did not run a converter.
What you will have at the end
One Python file, dax_converter_gaps.py, that downloads Microsoft’s 12 public sample Power BI reports, reads every DAX measure, calculation group and calculated table in them, checks each measure against the list on Snowflake’s Power BI ingestion page, follows references from one measure to another, checks its own result and writes dax_measure_status.csv, one row for each of the 271 measures with its status and cause. It needs the duckdb and pbixray Python modules (pbixray brings pandas), no account and no API key, and it does not call Claude. As published it downloads only the 12 pinned Microsoft files. To run it on your own .pbix files, change fetch() to read local paths and drop the pinned sizes and hashes.
The question behind it is the first one in any BI migration: how much of this estate will the converter leave to a person? Snowflake publishes what its Power BI ingestion does not convert. Reading that list against a real model is harder than it looks, because most of the measures it affects never name a listed function. They call another measure that does.
Method
The data
Twelve .pbix files from microsoft/powerbi-desktop-samples, pinned to commit 64ff6b1476050e07a9011adb0894d25c385dc1f1, under the MIT license (“Copyright (c) Microsoft Corporation”; read 2026-10-18). Eight come from the folder 2026 Power BI Samples Revamp and four from Sample Reports (Human Resources, Sales & Returns, Supply Chain, COVID-19 US Tracking). The file list in Run 1 shows each file’s path and byte size. GitHub’s raw file host sends no Last-Modified header, so the script pins each file by byte size and SHA-256 and stops if either differs. A commit never changes, so this guards against a typo or a corrupt download, not against the source moving.
The script reads the files with pbixray, which opens the model inside a .pbix without Power BI. It takes the measures, the calculation groups and their items, the calculated tables and the calculated columns. It reads only model definitions (measures, calculation groups, calculated tables and column names) and no row of report data.
The list
Snowflake’s page on Power BI ingestion feature support (read 2026-10-18) names the DAX it does not convert: KEEPFILTERS and time intelligence (PREVIOUSMONTH, SAMEPERIODLASTYEAR, TOTALYTD); SELECTEDVALUE, ALLSELECTED and ISFILTERED; MEDIAN, PERCENTILE and the STDEV and VAR families; SUMMARIZE, TOPN, RANKX, CONTAINS, ISEMPTY and COUNTROWS over VALUES; and RELATEDTABLE. It also says calculation groups and calculated tables “have no Snowflake table behind them, so they are dropped along with any measure that reads them”.
Databricks is not in the comparison, and the reason is a finding. Its page Import BI files using Genie Code (Microsoft Learn, dated 2026-10-15; read 2026-10-18) gives the accepted file types (.twb, .twbx, .tds, .tdsx and .pbit), a 100 MB limit for direct uploads and the requirement that partner-powered AI features are on. It lists no DAX function that the import cannot handle. We read that one page, so we say only that it gives us nothing to check a model against; you would learn what the import misses by running it.
The mapping table
The script turns that list into a table. Where a row says “Snowflake’s page”, the function is written there. Where it says “Our judgment”, we read a function seen in these reports as belonging to a family the page describes (“functions like” time intelligence, or selection functions). Those four rows are ours, so the script reports the result both ways: listed (Snowflake’s named functions, calculation groups and calculated tables) and broad (listed plus our four rows). Delete a row from MAPPING and the numbers move.
| Key | Category | Where the row comes from | Note | Measures that call it |
|---|---|---|---|---|
PREVIOUSMONTH | Time intelligence | Snowflake’s page | named in the time-intelligence sentence | 4 |
SAMEPERIODLASTYEAR | Time intelligence | Snowflake’s page | named in the time-intelligence sentence | 12 |
TOTALYTD | Time intelligence | Snowflake’s page | named in the time-intelligence sentence | 2 |
KEEPFILTERS | Filter context | Snowflake’s page | named | 4 |
SELECTEDVALUE | Selection | Snowflake’s page | named as describing what the reader clicked | 9 |
ALLSELECTED | Selection | Snowflake’s page | named as describing what the reader clicked | 3 |
ISFILTERED | Selection | Snowflake’s page | named as describing what the reader clicked | 0 |
MEDIAN | Statistical | Snowflake’s page | named; we also match MEDIANX | 0 |
PERCENTILE | Statistical | Snowflake’s page | named; we match PERCENTILE and PERCENTILEX in their .INC and .EXC forms | 0 |
STDEV | Statistical | Snowflake’s page | named as a family; we match STDEV and STDEVX in their .S and .P forms | 0 |
VAR | Statistical | Snowflake’s page | named as a family; we match VAR and VARX in their .S and .P forms (not the VAR keyword) | 0 |
SUMMARIZE | Table function | Snowflake’s page | named | 0 |
TOPN | Table function | Snowflake’s page | named | 0 |
RANKX | Table function | Snowflake’s page | named | 4 |
CONTAINS | Table function | Snowflake’s page | named | 0 |
ISEMPTY | Table function | Snowflake’s page | named | 0 |
COUNTROWS | Table function | Snowflake’s page | named only as COUNTROWS over VALUES; matched on that text pattern | 0 |
RELATEDTABLE | Related table | Snowflake’s page | named | 0 |
DATEADD | Time intelligence | Our judgment | our reading of “functions like” | 1 |
DATESINPERIOD | Time intelligence | Our judgment | our reading of “functions like” | 1 |
ISINSCOPE | Selection | Our judgment | our reading of the selection sentence | 3 |
ISCROSSFILTERED | Selection | Our judgment | our reading of the selection sentence | 4 |
Eleven of the functions Snowflake names are never called in these 12 reports (their count is 0). Those rows are in the table but were never exercised by these files, so treat their matching as untested here.
The steps
- Strip string literals and comments from each expression, so a function name inside text is not counted.
- Find the functions the expression calls, and match them against the table.
VARthe keyword is not theVAR.Sfunction. - Find what it reads: references to other measures (
[Profit]), columns ('Sales'[Amount]), calculation-group tables and calculated tables. Power BI’s own date tables, the ones it makes for date columns, are not counted as calculated tables. - Follow measure-to-measure references to any depth. A measure that calls nothing on the list, but builds on one that does, is Indirect. We assume it is affected too. Snowflake states that only for calculation groups and calculated tables, so this step is our reading of how metrics build on each other.
- Give each measure one status, in this order: Direct function, Calculation group, Calculated table, Indirect, Clear.
The resolver in step 4 is short. This is the function from the script, as published:
Python: reach every cause through the measures a measure references (excerpt of the script)def reach(sample, name, allowed, seen=()):
"""Causes reachable from a measure through the measures it references, with the path."""
out = {}
for _r, res in by_name.get((sample, name), []):
if res is None:
continue
causes, refs, _ = res
for c in causes:
if c[0] in allowed:
out.setdefault(c, [name])
for ref in refs:
if ref in seen or ref == name:
continue
for c, p in reach(sample, ref, allowed, seen + (name,)).items():
out.setdefault(c, [name] + p)
return out
The script checks its own result
Before it prints a result table or writes the CSV, the script runs six checks. Each is listed here with the planted fault that trips it (Run 2 plants all of them):
- Rows loaded equal rows read from the files. Tripped by a duplicated or invented row.
- No measure appears twice in one report. Tripped by a duplicated row.
- Every measure has an expression. Tripped by a missing value.
- Every reference resolves to a measure or a column. Tripped by a reference to a measure that does not exist.
- Every key Snowflake names appears in Snowflake’s text. The script holds the page’s sentences. Tripped by a typo in a key.
- Every key we added is a function the reports actually call. Tripped by a typo in one of our rows.
The last two are not the check “every key matches a real input”, because eleven of Snowflake’s keys legitimately match nothing in these reports. A key that matches no input is not silently routed anywhere in this script, since a measure with no match is simply Clear. After a clean run it also reads the CSV back and checks that it holds as many rows as the source and that its status shares add to 100%. That check guards the writing step, and none of the planted faults trips it. If any check fails, the script prints the failure, writes no CSV and exits with code 1.
Run 1. The measurement
Run as published (python3 dax_converter_gaps.py), the six checks passed and the script wrote the CSV. First, the files it fetched:
Source: microsoft/powerbi-desktop-samples, commit 64ff6b1476050e07a9011adb0894d25c385dc1f1, MIT license Mode: RUN 1, the measurement Versions: python 3.14.8, duckdb 1.5.6, pbixray 0.15.5, pandas 3.0.6 file bytes last-modified sha256 (first 12) 2026 Power BI Samples Revamp/AdventureWorks Sales.pbix 8390782 not sent 76c89b64cd1c 2026 Power BI Samples Revamp/Artificial Intelligence Sample.pbix 1120296 not sent 170a12914593 2026 Power BI Samples Revamp/Competitive Marketing Analysis.pbix 6612086 not sent 00157525c8c8 2026 Power BI Samples Revamp/Corporate Spend.pbix 716704 not sent 2e56785790d2 2026 Power BI Samples Revamp/Employee Hiring and History.pbix 8483276 not sent 0763c8390a71 2026 Power BI Samples Revamp/Regional Sales Sample.pbix 1018743 not sent 050e6b8549c2 2026 Power BI Samples Revamp/Revenue Opportunities.pbix 245930 not sent 3c95c4fb5a26 2026 Power BI Samples Revamp/Store Sales.pbix 9701642 not sent 160b5b48b74e Sample Reports/Human Resources Sample PBIX.pbix 8700848 not sent f2de1fa89ef6 Sample Reports/Sales & Returns Sample v201912.pbix 6530381 not sent c0a51fbae6c9 Sample Reports/Supply Chain Sample.pbix 1177398 not sent dd55cac1348f Sample Reports/COVID-19 US Tracking Sample.pbix 6917621 not sent 3f0410a4fc09 Total downloaded: 59,615,707 bytes in 12 files
Then the checks and the results:
Output from our run: checks, mapping hits, then resultsChecks [pass] rows loaded = rows pbixray reads from the files: loaded 271, source 271 (tripped by: a duplicated or invented row) [pass] no measure appears twice in one report: 0 duplicated name(s) (tripped by: a duplicated row) [pass] every measure has an expression: 0 without one (tripped by: a missing value) [pass] every reference resolves to a measure or column: 0 unresolved (tripped by: a reference to a measure that does not exist) [pass] every vendor-named key is in the vendor's text: not found: none (tripped by: a typo in a vendor-named key) [pass] every family key is a function seen in the reports: not seen: none (tripped by: a typo in a family key) Keys of the mapping table and where they matched (all 12 reports) key category basis measures hit directly PREVIOUSMONTH Time intelligence named 4 SAMEPERIODLASTYEAR Time intelligence named 12 TOTALYTD Time intelligence named 2 KEEPFILTERS Filter context named 4 SELECTEDVALUE Selection named 9 ALLSELECTED Selection named 3 ISFILTERED Selection named 0 MEDIAN Statistical named 0 PERCENTILE Statistical named 0 STDEV Statistical named 0 VAR Statistical named 0 SUMMARIZE Table function named 0 TOPN Table function named 0 RANKX Table function named 4 CONTAINS Table function named 0 ISEMPTY Table function named 0 COUNTROWS Table function named 0 RELATEDTABLE Related table named 0 DATEADD Time intelligence family 1 DATESINPERIOD Time intelligence family 1 ISINSCOPE Selection family 3 ISCROSSFILTERED Selection family 4 (a key with 0 hits is named by the vendor but not used by any of these 12 reports) By report: measures that need a person (status other than Clear) report measures listed share broad share missed by direct scan AdventureWorks Sales 20 10 50.0% 10 50.0% 9 Artificial Intelligence Sample 29 15 51.7% 15 51.7% 8 COVID-19 US Tracking Sample 11 1 9.1% 1 9.1% 0 Competitive Marketing Analysis 22 8 36.4% 9 40.9% 5 Corporate Spend 19 19 100.0% 19 100.0% 17 Employee Hiring and History 25 15 60.0% 15 60.0% 10 Human Resources Sample PBIX 30 15 50.0% 15 50.0% 10 Regional Sales Sample 15 10 66.7% 10 66.7% 7 Revenue Opportunities 6 0 0.0% 0 0.0% 0 Sales & Returns Sample v201912 58 46 79.3% 46 79.3% 35 Store Sales 32 0 0.0% 0 0.0% 0 Supply Chain Sample 4 0 0.0% 0 0.0% 0 ALL 12 REPORTS 271 139 51.3% 140 51.7% 101 By status (listed = Snowflake's named functions, calculation groups and calculated tables; broad adds the family rows) status listed share broad share Direct function 38 14.0% 44 16.2% Calculation group 9 3.3% 9 3.3% Calculated table 4 1.5% 3 1.1% Indirect 88 32.5% 84 31.0% Clear 132 48.7% 131 48.3% Total 271 100.0% 271 100.0% Direct function hits by category (a measure counts once per category) Time intelligence 20 Selection 18 Filter context 4 Table function 4 Objects that are not measures (Snowflake drops calculation groups and calculated tables) report calc groups calc items calc tables auto date calc columns AdventureWorks Sales 2 13 0 0 4 Artificial Intelligence Sample 0 0 3 0 9 COVID-19 US Tracking Sample 0 0 0 2 16 Competitive Marketing Analysis 0 0 1 0 10 Corporate Spend 0 0 2 0 2 Employee Hiring and History 0 0 0 0 7 Human Resources Sample PBIX 0 0 0 6 43 Regional Sales Sample 0 0 3 0 5 Revenue Opportunities 0 0 1 0 1 Sales & Returns Sample v201912 0 0 1 2 15 Store Sales 0 0 2 0 7 Supply Chain Sample 0 0 0 1 8 ALL 12 REPORTS 2 13 13 11 127 (auto date = tables Power BI makes itself for date/time columns; not counted as calc tables) CSV written: dax_measure_status.csv, 271 rows (source 271); status shares sum to 100.0%: pass
Result table: measures that need a person, by report
The same numbers as the run, using the listed view. “Found by searching” counts measures that call a listed function themselves. The rest of the flagged measures reach a cause only through other measures, a calculation group or a calculated table.
| Report | Measures | Found by searching for the listed functions | Flagged after following references | Share flagged | Missed by the search |
|---|---|---|---|---|---|
| AdventureWorks Sales | 20 | 1 | 10 | 50.0% | 9 |
| Artificial Intelligence Sample | 29 | 7 | 15 | 51.7% | 8 |
| COVID-19 US Tracking Sample | 11 | 1 | 1 | 9.1% | 0 |
| Competitive Marketing Analysis | 22 | 3 | 8 | 36.4% | 5 |
| Corporate Spend | 19 | 2 | 19 | 100.0% | 17 |
| Employee Hiring and History | 25 | 5 | 15 | 60.0% | 10 |
| Human Resources Sample PBIX | 30 | 5 | 15 | 50.0% | 10 |
| Regional Sales Sample | 15 | 3 | 10 | 66.7% | 7 |
| Revenue Opportunities | 6 | 0 | 0 | 0.0% | 0 |
| Sales & Returns Sample v201912 | 58 | 11 | 46 | 79.3% | 35 |
| Store Sales | 32 | 0 | 0 | 0.0% | 0 |
| Supply Chain Sample | 4 | 0 | 0 | 0.0% | 0 |
| All 12 reports | 271 | 38 | 139 | 51.3% | 101 |
Download: dax_measure_status.csv (columns: report, sample set, table, measure, status under the listed and broad views, causes, the chain of measures to follow, whether a direct search finds it, and the expression length).
Result table: by status
| Status | Measures | Share |
|---|---|---|
| Direct function | 38 | 14.0% |
| Calculation group | 9 | 3.3% |
| Calculated table | 4 | 1.5% |
| Indirect | 88 | 32.5% |
| Clear | 132 | 48.7% |
| Total | 271 | 100.0% |
This is the listed view. The broad view, which adds our four rows, flags 140 of 271 (51.7%), so those rows change little here.
What else the reports hold
Snowflake drops calculation groups and calculated tables, so the script counts them too.
| Report | Calculation groups | Calculation items | Calculated tables | Auto date tables | Calculated columns |
|---|---|---|---|---|---|
| AdventureWorks Sales | 2 | 13 | 0 | 0 | 4 |
| Artificial Intelligence Sample | 0 | 0 | 3 | 0 | 9 |
| COVID-19 US Tracking Sample | 0 | 0 | 0 | 2 | 16 |
| Competitive Marketing Analysis | 0 | 0 | 1 | 0 | 10 |
| Corporate Spend | 0 | 0 | 2 | 0 | 2 |
| Employee Hiring and History | 0 | 0 | 0 | 0 | 7 |
| Human Resources Sample PBIX | 0 | 0 | 0 | 6 | 43 |
| Regional Sales Sample | 0 | 0 | 3 | 0 | 5 |
| Revenue Opportunities | 0 | 0 | 1 | 0 | 1 |
| Sales & Returns Sample v201912 | 0 | 0 | 1 | 2 | 15 |
| Store Sales | 0 | 0 | 2 | 0 | 7 |
| Supply Chain Sample | 0 | 0 | 0 | 1 | 8 |
| All 12 reports | 2 | 13 | 13 | 11 | 127 |
What the numbers say
- A text search misses most of the problem. Searching for the listed functions finds 38 of 271 measures (14.0%). Following references, calculation groups and calculated tables flags 139 (51.3%), so 101 flagged measures contain no listed function themselves.
- One base measure can flag a whole report. In Corporate Spend every one of 19 measures is flagged. One measure,
Amount, calls TOTALYTD; 17 others build on it, and theviacolumn in the CSV shows each chain. The 19th,IT Area/Sub Area, calls SELECTEDVALUE itself. Under our indirect rule, rewriting the base measure would clear the dependents. - The spread between reports is wide. Revenue Opportunities, Store Sales and Supply Chain have no flagged measure; Sales & Returns has 46 of 58 (79.3%). The average hides this, so run it per report before sizing a migration.
- Calculation groups sit in one report. AdventureWorks Sales has two calculation groups with 13 items. Nine of its measures read them, and 10 of its 20 measures are flagged. Six of the nine read the Time Intelligence group, whose items call SAMEPERIODLASTYEAR and the DATESMTD, DATESQTD and DATESYTD functions, so those measures never show it themselves. The other three read a Date Role group that switches relationships with USERELATIONSHIP. Both facts come from reading the items in the pinned file, not from a run file.
- Time intelligence and selection functions lead. Of the 44 measures that call a function directly in the broad view (38 in the listed view), 20 call a time-intelligence function themselves and 18 a selection function (a measure counts once per category, and some have both).
For the decision “migrate, rebuild, keep or retire”, the CSV gives a sorting aid and not a verdict. A report with no flagged measure is a candidate for the platform converter’s first pass plus a parity test. A report with many Indirect measures is cheaper than its flagged share suggests if the causes sit in a few base measures. Which reports to retire depends on usage, which these files do not hold. Snowflake’s page also gives a second reason a measure is not converted: it is “not valid as a semantic-view metric”, because a metric must be a single aggregate over its own table’s rows. We did not test that rule, so for the question of where a metric should live, the flagged share is a floor, not a forecast.
Run 2. Planted faults the checks must catch
The same script with --plant adds five rows to the AdventureWorks Sales measures after reading them: a copy of the measure Cost; Planted Sales PY, which calls SAMEPERIODLASTYEAR; Planted PY Margin, which reaches it only through Planted Sales PY; Planted Broken Ref, which references a measure that does not exist; and Planted Empty, which has no expression. It also adds two misspelled keys to the mapping table. The files themselves are untouched. The script must report the faults and refuse to write a CSV.
Checks [FAIL] rows loaded = rows pbixray reads from the files: loaded 276, source 271 (tripped by: a duplicated or invented row) [FAIL] no measure appears twice in one report: 1 duplicated name(s) (tripped by: a duplicated row) [FAIL] every measure has an expression: 1 without one (tripped by: a missing value) [FAIL] every reference resolves to a measure or column: 1 unresolved AdventureWorks Sales: Planted Broken Ref -> [No Such Measure] (tripped by: a reference to a measure that does not exist) [FAIL] every vendor-named key is in the vendor's text: not found: SAMEPERIODLASTYAER (tripped by: a typo in a vendor-named key) [FAIL] every family key is a function seen in the reports: not seen: ISINSCOP (tripped by: a typo in a family key) Planted measures, as the resolver sees them Planted Broken Ref status=Clear via=- a direct-call scan finds it: no Planted Empty status=Unscorable via=- a direct-call scan finds it: no Planted PY Margin status=Indirect via=planted sales py a direct-call scan finds it: no Planted Sales PY status=Direct function via=- a direct-call scan finds it: yes STOPPED: 6 check(s) failed. No result table and no CSV from this run.
All six checks failed and the script exited with code 1, so it wrote no result table and no CSV. The planted-measure lines show the case the whole page is about. Planted PY Margin never names a time-intelligence function, so a direct search does not find it (a direct-call scan finds it: no), but the resolver reports it as Indirect, through planted sales py. The broken reference is the opposite case: the resolver calls Planted Broken Ref Clear, because it can see nothing on the list in it, and only the reference check catches it. That is why the script does not rely on one check.
Limits
- This is a prediction from a list, not a converter test. We did not run Snowflake’s or Databricks’ converter. A function on the list may convert in a simple case, and a measure we call Clear may still fail for a reason the page does not list. Run the converter on your own file to know.
- It is not a client result. These are Microsoft’s demonstration reports, built to show Power BI features. Real estates differ, and the shares above say nothing about yours.
- Twelve reports, 271 measures. It is a small, convenient sample from one publisher. Do not read 51.3% as an industry rate.
- The vendor’s list is the standard, and the vendor can change it. We read it on 2026-10-18. Snowflake’s page also says “functions like” for time intelligence. Our four family rows are one reading of that; another reader would add more.
- The indirect rule is our assumption. Snowflake states that measures reading a calculation group or calculated table are dropped. That a measure depending on a flagged measure is also affected is our reading, though a dependent measure cannot be built if the measure under it is missing.
- Only the model is read. Snowflake says measures saved in the report rather than in the data model are not read, and we read only model measures too. Tableau files, Tableau’s LOD expressions and row-level security are out of scope.
- Other parts of the Snowflake list were not tested: the single-aggregate rule for semantic-view metrics, data entered directly in Power BI, and tables that are not Snowflake-backed. The flagged share is therefore a floor for the reasons Snowflake publishes.
- Databricks has no column here. The import page we read lists no functions, so we say nothing about what its import misses.
- The parser is ours. It handles the expressions in these 12 files and the checks flag any reference it cannot resolve, but DAX has more syntax than these files use.
Tested on
Tested on
- Platform
- DuckDB 1.5.6, run from Python with pbixray and pandas (versions are printed in the first line of the run output). No Snowflake or Databricks converter was run.
- Edition
- Open-source DuckDB library (
pip install duckdb pbixray) - Date
- 2026-10-18
- Dataset
- 12 sample reports from microsoft/powerbi-desktop-samples, commit
64ff6b1476050e07a9011adb0894d25c385dc1f1, MIT license, each file pinned by byte size and SHA-256 - List
- Snowflake: Power BI ingestion feature support (read 2026-10-18)
- Code
- dax_converter_gaps.py
- Result
- dax_measure_status.csv
The script pins the commit and every file, sorts its output and uses no random numbers, so another run on the same files gives the same numbers. Run it with python3 dax_converter_gaps.py (the measurement) or add --plant (the planted faults). Downloads land in loop-out/data/dax-samples unless you pass --data-dir.
When this is not enough
- You need to know what the converter really does with your reports. This predicts from a list. Only a run of the converter on your file shows what it converts, and a parity test shows whether the converted numbers match the old ones.
- Your reports are Tableau, or sit on Databricks. The method carries over, but the Databricks import page we read lists no functions to check against, and Tableau workbooks need their own parser.
- You must decide what to retire. Usage logs, owners and overlap decide that, and no file in a report holds them.
- Your measures live in the report, not the model. Snowflake’s page says it does not read them, and neither does this script.
Find out what your own reports would need
These numbers come from Microsoft’s sample reports, not yours. Our fixed-fee migration assessment starts with your platform’s own converter. Claude then fills what it misses and writes a parity test for every measure.
Your report owners sign off before the old report is switched off. Your data must already be on Databricks or Snowflake. The script in this guide only downloads the 12 sample files, so it needs adapting before it can read yours.