DAX Optimization
This skill explains how to determine optimization techniques for a DAX query and
its components, and documents the executable rule catalog
(dax_optimization_rules.json) that powers the Performance analysis tab of the
interactive DAX test widget.
The runtime rule engine lives in
src/sempy_labs/semantic_model/_dax_optimization.py and loads its rules from
src/sempy_labs/semantic_model/_dax_optimization_rules.json (the canonical copy
that is packaged and executed). The JSON in this skill folder is the same schema
and is the human-facing reference; keep the two in sync when adding rules.
When to Use This Skill
- Diagnosing why a DAX query is slow.
- Interpreting Formula Engine (FE) vs Storage Engine (SE) timings from a trace.
- Reading a DAX query plan (logical/physical) for
CallbackDataID, spools, scans.
- Deciding whether high column cardinality is the bottleneck.
- Adding, editing, or reviewing the performance-analysis rules.
The Inputs
The analysis is computed from up to six artifacts. Each rule declares which
artifacts it requires; a rule is skipped if any required artifact is missing.
| Input |
Source |
What it provides |
| DAX query |
The editor text plus the DAX expressions of every measure the query transitively depends on |
Syntax-level patterns (IFERROR, FILTER over a full table, nested iterators, raw / division). The query usually references a measure only by name, so the syntax rules also scan the DAX of dependent measures (resolved from model metadata) to catch issues that live inside those measures. |
| Model metadata |
TOM (connect_semantic_model) |
Tables, columns, measures, relationships, data types. |
| Query dependencies |
INFO.CALCDEPENDENCY |
The exact tables/columns the query references. |
| Trace details |
Server-side trace |
QueryEnd, VertiPaqSEQueryEnd, cache matches → total/FE/SE duration, CPU, SE query count, parallelism. |
| DAX query plan |
Trace DAXQueryPlan events |
Logical/physical plan text → CallbackDataID, Spool, scan operators. |
| Vertipaq Analyzer |
vertipaq_analyzer(...) |
Column cardinality, size, encoding, data types. Only used when the cardinalities of the Data columns are not all 1 — otherwise there is nothing meaningful to analyze and Vertipaq rules are skipped. |
Optimization Methodology
Work top-down, from the cheapest signal to the most detailed.
1. Establish the engine balance (FE vs SE)
The Storage Engine (VertiPaq) is multi-threaded and fast; the Formula Engine is
single-threaded. From the trace:
Total Duration = QueryEnd.Duration
SE Duration = sum of VertiPaqSEQueryEnd.Duration (excluding Internal subqueries)
FE Duration = Total − SE
Then:
- SE-bound (
SE% ≥ 70%): the query spends its time scanning data → attack
data volume and cardinality (rule SE_BOUND).
- FE-bound (
FE% ≥ 70%): the query spends its time in single-threaded logic
→ push work to the SE, remove callbacks, reduce materialization (rule
FE_BOUND).
2. Look for CallbackDataID (the #1 red flag)
CallbackDataID in the physical plan means the SE had to call back into the
FE mid-scan. It disables VertiPaq optimizations and is usually caused by:
IF / IFERROR / ISERROR / error handling inside an iterator,
- division by
/ (wrap in DIVIDE),
- rounding / date arithmetic / conditional logic evaluated row-by-row.
Rules: CALLBACK_DATA_ID, USES_IFERROR, DIVISION_WITHOUT_DIVIDE.
3. Count and size the Storage Engine queries
- Many SE queries (
≥ 10) usually means fusion failed — simplify filter
context and use variables to compute base values once (MANY_SE_QUERIES).
- A single slow scan (
≥ 50 ms) points at a specific large/high-cardinality
table — inspect its xmSQL in the plan (SLOW_SE_SCAN).
- Low parallelism (
SE CPU / SE Duration < 1.2x over a meaningful SE
duration) means scans are effectively single-threaded (LOW_SE_PARALLELISM).
4. Inspect the query plan for materialization
Large/many spools materialize intermediate results in the FE and cost memory
and time (LARGE_SPOOL). Reduce them with variables and earlier filtering.
5. Reduce cardinality (Vertipaq)
Cardinality drives dictionary size, scan cost, and DISTINCTCOUNT/join cost.
Focus on columns the query actually references:
- High-cardinality columns (
≥ 1,000,000 unique values): split datetime
into date+time, bucket/round numerics, drop unused keys (HIGH_CARDINALITY_COLUMN).
- High-cardinality floating point columns are especially expensive — convert
to fixed decimal/integer or round (
FLOAT_HIGH_CARDINALITY_COLUMN).
6. Simplify the DAX itself
- Nested iterators multiply row evaluations (
NESTED_ITERATORS).
- Many iterators (
≥ 5) raise the chance of row-by-row work (MANY_ITERATORS).
- FILTER over a whole table to evaluate a measure (e.g.
FILTER(Sales, [Total Qty] > 100)) tests the measure on every row of the table — iterate the
smallest grouping instead, e.g. FILTER(VALUES(Sales[OrderId]), [Total Qty] > 100) (FILTER_FULL_TABLE). This rule fires only when the FILTER predicate
references a measure.
- FILTER wrapping a column predicate (e.g.
FILTER(Customer, Customer[Category] = "A")) materializes the whole table for a condition over
its columns — rewrite as KEEPFILTERS(Customer[Category] = "A")
(FILTER_COLUMN_USE_KEEPFILTERS). This rule fires when the FILTER predicate
references columns (and no measure). Measure vs. column is resolved from
model metadata when available, otherwise inferred from whether the bracket
reference is table-qualified.
- Many referenced columns (
≥ 15) widen datacaches — project only what's
needed (MANY_REFERENCED_COLUMNS).
7. Diagnostics
If the trace or plan wasn't captured, the engine emits an informational finding
(NO_TRACE_CAPTURED, NO_QUERY_PLAN_CAPTURED) telling the user to run the query
first so the full analysis can be produced.
The Rules JSON Schema
Each entry in rules is one rule:
{
"id": "CALLBACK_DATA_ID", // stable identifier
"title": "…", // short headline
"category": "Query plan", // grouping label
"severity": "high|medium|low|info",
"requires": ["query_plan"], // artifacts that must be present
"kind": "scalar|for_each", // evaluation mode
"condition": { … }, // scalar rules: evaluated against metrics
"collection": "high_cardinality_columns", // for_each rules: list to iterate
"where": { … }, // for_each rules: per-item filter
"max_findings": 8, // for_each rules: cap on emitted findings
"message": "… {placeholder} …", // templated; {tokens} filled from context
"recommendation": "…",
"references": ["https://…"]
}
Conditions
A condition is a tree of:
- Leaf —
{"metric": "se_pct", "op": ">=", "value": 0.7} for scalar rules,
or {"field": "cardinality", "op": ">=", "value": 1000000} inside a
for_each where.
- Composite —
{"all": [ … ]}, {"any": [ … ]}, {"not": { … }}.
Operators: >, >=, <, <=, ==, !=, contains, not_contains,
regex, in, not_in. Unknown operators / type errors evaluate to false,
so a malformed rule can never crash the analysis.
Available metrics (scalar context)
has_query, query_length, iterator_count, nested_iterator,
uses_iferror, uses_divide_function, uses_division_operator,
filter_full_table_count, filter_column_predicate_count, cold_cache,
has_trace, total_duration_ms,
se_duration_ms, fe_duration_ms, cpu_time_ms, se_pct, fe_pct,
se_query_count, se_internal_count, se_cache_match_count, se_cpu_ms,
se_parallelism, has_query_plan, callback_dataid_count,
encode_callback_count, spool_count, referenced_column_count,
referenced_table_count, has_dependencies, vertipaq_available,
vertipaq_skipped_trivial, max_data_column_cardinality,
high_cardinality_data_column_count. Display helpers: se_pct_display,
fe_pct_display, se_parallelism_display.
Available collections (for_each context)
| Collection |
Item fields |
high_cardinality_columns |
table, column, cardinality, cardinality_display, data_type, is_floating_point, data_size, encoding |
slow_se_queries |
subclass, duration, cpu |
message placeholders for for_each rules can reference any item field as well
as any scalar metric.
Adding or Editing a Rule
- Add the rule object to both JSON copies (package + this skill folder).
- If the rule needs a new metric or collection, add it in
build_context() in _dax_optimization.py.
- Keep
severity honest: reserve high for things that clearly dominate
runtime (e.g. CallbackDataID, IFERROR).
- Provide an actionable
recommendation and at least one authoritative
reference (SQLBI or Microsoft Learn).
- Validate:
python -c "import json,sys; json.load(open('src/sempy_labs/semantic_model/_dax_optimization_rules.json'))".
References
1---2name: dax-optimization3description: Methodology and an executable rule catalog for diagnosing and optimizing DAX query performance. Use this when analyzing a slow DAX query, interpreting trace timings / DAX query plans, reducing column cardinality, or extending the performance-analysis rules used by the interactive DAX test widget (sempy_labs.semantic_model.test).4---56# DAX Optimization78This skill explains how to determine optimization techniques for a DAX query and9its components, and documents the **executable rule catalog**10(`dax_optimization_rules.json`) that powers the *Performance analysis* tab of the11interactive DAX test widget.1213The runtime rule engine lives in14`src/sempy_labs/semantic_model/_dax_optimization.py` and loads its rules from15`src/sempy_labs/semantic_model/_dax_optimization_rules.json` (the canonical copy16that is packaged and executed). The JSON in this skill folder is the same schema17and is the human-facing reference; keep the two in sync when adding rules.1819## When to Use This Skill2021- Diagnosing why a DAX query is slow.22- Interpreting Formula Engine (FE) vs Storage Engine (SE) timings from a trace.23- Reading a DAX query plan (logical/physical) for `CallbackDataID`, spools, scans.24- Deciding whether high column cardinality is the bottleneck.25- Adding, editing, or reviewing the performance-analysis rules.2627---2829## The Inputs3031The analysis is computed from up to six artifacts. Each rule declares which32artifacts it `requires`; a rule is skipped if any required artifact is missing.3334| Input | Source | What it provides |35|-------|--------|------------------|36| **DAX query** | The editor text **plus the DAX expressions of every measure the query transitively depends on** | Syntax-level patterns (IFERROR, FILTER over a full table, nested iterators, raw `/` division). The query usually references a measure only by name, so the syntax rules also scan the DAX of dependent measures (resolved from model metadata) to catch issues that live inside those measures. |37| **Model metadata** | TOM (`connect_semantic_model`) | Tables, columns, measures, relationships, data types. |38| **Query dependencies** | `INFO.CALCDEPENDENCY` | The exact tables/columns the query references. |39| **Trace details** | Server-side trace | `QueryEnd`, `VertiPaqSEQueryEnd`, cache matches → total/FE/SE duration, CPU, SE query count, parallelism. |40| **DAX query plan** | Trace `DAXQueryPlan` events | Logical/physical plan text → `CallbackDataID`, `Spool`, scan operators. |41| **Vertipaq Analyzer** | `vertipaq_analyzer(...)` | Column cardinality, size, encoding, data types. **Only used when the cardinalities of the `Data` columns are not all `1`** — otherwise there is nothing meaningful to analyze and Vertipaq rules are skipped. |4243---4445## Optimization Methodology4647Work top-down, from the cheapest signal to the most detailed.4849### 1. Establish the engine balance (FE vs SE)5051The Storage Engine (VertiPaq) is multi-threaded and fast; the Formula Engine is52single-threaded. From the trace:5354- `Total Duration = QueryEnd.Duration`55- `SE Duration = sum of VertiPaqSEQueryEnd.Duration` (excluding `Internal` subqueries)56- `FE Duration = Total − SE`5758Then:59- **SE-bound** (`SE% ≥ 70%`): the query spends its time scanning data → attack60 **data volume and cardinality** (rule `SE_BOUND`).61- **FE-bound** (`FE% ≥ 70%`): the query spends its time in single-threaded logic62 → push work to the SE, remove callbacks, reduce materialization (rule63 `FE_BOUND`).6465### 2. Look for `CallbackDataID` (the #1 red flag)6667`CallbackDataID` in the **physical plan** means the SE had to call back into the68FE mid-scan. It disables VertiPaq optimizations and is usually caused by:6970- `IF` / `IFERROR` / `ISERROR` / error handling inside an iterator,71- division by `/` (wrap in `DIVIDE`),72- rounding / date arithmetic / conditional logic evaluated row-by-row.7374Rules: `CALLBACK_DATA_ID`, `USES_IFERROR`, `DIVISION_WITHOUT_DIVIDE`.7576### 3. Count and size the Storage Engine queries7778- **Many SE queries** (`≥ 10`) usually means fusion failed — simplify filter79 context and use variables to compute base values once (`MANY_SE_QUERIES`).80- **A single slow scan** (`≥ 50 ms`) points at a specific large/high-cardinality81 table — inspect its xmSQL in the plan (`SLOW_SE_SCAN`).82- **Low parallelism** (`SE CPU / SE Duration < 1.2x` over a meaningful SE83 duration) means scans are effectively single-threaded (`LOW_SE_PARALLELISM`).8485### 4. Inspect the query plan for materialization8687Large/many **spools** materialize intermediate results in the FE and cost memory88and time (`LARGE_SPOOL`). Reduce them with variables and earlier filtering.8990### 5. Reduce cardinality (Vertipaq)9192Cardinality drives dictionary size, scan cost, and `DISTINCTCOUNT`/join cost.93Focus on **columns the query actually references**:9495- **High-cardinality columns** (`≥ 1,000,000` unique values): split datetime96 into date+time, bucket/round numerics, drop unused keys (`HIGH_CARDINALITY_COLUMN`).97- **High-cardinality floating point** columns are especially expensive — convert98 to fixed decimal/integer or round (`FLOAT_HIGH_CARDINALITY_COLUMN`).99100### 6. Simplify the DAX itself101102- **Nested iterators** multiply row evaluations (`NESTED_ITERATORS`).103- **Many iterators** (`≥ 5`) raise the chance of row-by-row work (`MANY_ITERATORS`).104- **FILTER over a whole table to evaluate a measure** (e.g. `FILTER(Sales,105 [Total Qty] > 100)`) tests the measure on every row of the table — iterate the106 smallest grouping instead, e.g. `FILTER(VALUES(Sales[OrderId]), [Total Qty] >107 100)` (`FILTER_FULL_TABLE`). This rule fires only when the FILTER predicate108 references a **measure**.109- **FILTER wrapping a column predicate** (e.g. `FILTER(Customer,110 Customer[Category] = "A")`) materializes the whole table for a condition over111 its columns — rewrite as `KEEPFILTERS(Customer[Category] = "A")`112 (`FILTER_COLUMN_USE_KEEPFILTERS`). This rule fires when the FILTER predicate113 references **columns** (and no measure). Measure vs. column is resolved from114 model metadata when available, otherwise inferred from whether the bracket115 reference is table-qualified.116- **Many referenced columns** (`≥ 15`) widen datacaches — project only what's117 needed (`MANY_REFERENCED_COLUMNS`).118119### 7. Diagnostics120121If the trace or plan wasn't captured, the engine emits an informational finding122(`NO_TRACE_CAPTURED`, `NO_QUERY_PLAN_CAPTURED`) telling the user to run the query123first so the full analysis can be produced.124125---126127## The Rules JSON Schema128129Each entry in `rules` is one rule:130131```jsonc132{133 "id": "CALLBACK_DATA_ID", // stable identifier134 "title": "…", // short headline135 "category": "Query plan", // grouping label136 "severity": "high|medium|low|info",137 "requires": ["query_plan"], // artifacts that must be present138 "kind": "scalar|for_each", // evaluation mode139 "condition": { … }, // scalar rules: evaluated against metrics140 "collection": "high_cardinality_columns", // for_each rules: list to iterate141 "where": { … }, // for_each rules: per-item filter142 "max_findings": 8, // for_each rules: cap on emitted findings143 "message": "… {placeholder} …", // templated; {tokens} filled from context144 "recommendation": "…",145 "references": ["https://…"]146}147```148149### Conditions150151A condition is a tree of:152153- **Leaf** — `{"metric": "se_pct", "op": ">=", "value": 0.7}` for scalar rules,154 or `{"field": "cardinality", "op": ">=", "value": 1000000}` inside a155 `for_each` `where`.156- **Composite** — `{"all": [ … ]}`, `{"any": [ … ]}`, `{"not": { … }}`.157158Operators: `>`, `>=`, `<`, `<=`, `==`, `!=`, `contains`, `not_contains`,159`regex`, `in`, `not_in`. Unknown operators / type errors evaluate to `false`,160so a malformed rule can never crash the analysis.161162### Available metrics (scalar context)163164`has_query`, `query_length`, `iterator_count`, `nested_iterator`,165`uses_iferror`, `uses_divide_function`, `uses_division_operator`,166`filter_full_table_count`, `filter_column_predicate_count`, `cold_cache`,167`has_trace`, `total_duration_ms`,168`se_duration_ms`, `fe_duration_ms`, `cpu_time_ms`, `se_pct`, `fe_pct`,169`se_query_count`, `se_internal_count`, `se_cache_match_count`, `se_cpu_ms`,170`se_parallelism`, `has_query_plan`, `callback_dataid_count`,171`encode_callback_count`, `spool_count`, `referenced_column_count`,172`referenced_table_count`, `has_dependencies`, `vertipaq_available`,173`vertipaq_skipped_trivial`, `max_data_column_cardinality`,174`high_cardinality_data_column_count`. Display helpers: `se_pct_display`,175`fe_pct_display`, `se_parallelism_display`.176177### Available collections (`for_each` context)178179| Collection | Item fields |180|------------|-------------|181| `high_cardinality_columns` | `table`, `column`, `cardinality`, `cardinality_display`, `data_type`, `is_floating_point`, `data_size`, `encoding` |182| `slow_se_queries` | `subclass`, `duration`, `cpu` |183184`message` placeholders for `for_each` rules can reference any item field as well185as any scalar metric.186187---188189## Adding or Editing a Rule1901911. Add the rule object to **both** JSON copies (package + this skill folder).1922. If the rule needs a new metric or collection, add it in193 `build_context()` in `_dax_optimization.py`.1943. Keep `severity` honest: reserve `high` for things that clearly dominate195 runtime (e.g. `CallbackDataID`, `IFERROR`).1964. Provide an actionable `recommendation` and at least one authoritative197 `reference` (SQLBI or Microsoft Learn).1985. Validate: `python -c "import json,sys; json.load(open('src/sempy_labs/semantic_model/_dax_optimization_rules.json'))"`.199200---201202## References203204- SQLBI — Understanding DAX query plans: https://www.sqlbi.com/articles/understanding-dax-query-plans/205- SQLBI — Optimizing high-cardinality columns in VertiPaq: https://www.sqlbi.com/articles/optimizing-high-cardinality-columns-in-vertipaq/206- SQLBI — Error handling in DAX measures: https://www.sqlbi.com/articles/error-handling-in-dax-measures/207- Microsoft Learn — DIVIDE function: https://learn.microsoft.com/dax/divide-function-dax208- Microsoft Learn — Data reduction techniques for import modeling: https://learn.microsoft.com/power-bi/guidance/import-modeling-data-reduction