The idea
Most DAX you have written so far reads a whole column: SUM(order_items[line_total]). An
iterator is the other way to read a table. SUMX, AVERAGEX, MAXX, RANKX and COUNTX
take a table and an expression, visit the table one row at a time, work the expression out on that
row, and only then collapse the results (add them, average them, rank them).
While the iterator stands on a row, the columns of that row are available by name. That "current row" is the row context. It is a position in a table, nothing more. It is not a filter, and that difference is behind most iterator bugs.
Figure 1
SUMX walking order 1004, one line at a time
| order_item_id | quantity | unit_price | discount_pct |
|---|---|---|---|
| 10 | 1 | 92.99 | 10 |
| 11 | 2 | 20.99 | 10 |
| 12 | 1 | 149.00 | 10 |
| 13 | 2 | 66.99 | 10 |
| order_item_id | net |
|---|---|
| 10 | 83.69 |
| 11 | 37.78 |
| 12 | 134.10 |
| 13 | 120.58 |
| Net Revenue |
|---|
| $376.15 |
- Four lines of one real order.
SUMX(order_items, quantity * unit_price * (1 - discount_pct / 100))will visit them in turn. - Line 10: while the iterator stands on this row,
quantity,unit_priceanddiscount_pctmean this row's values. That is the row context. 1 x 92.99 x 0.9 = 83.69. - Lines 11, 12 and 13 follow the same way. Each one is worked out on its own before anything is added.
- Only now does the X function add: 83.69 + 37.78 + 134.10 + 120.58 = 376.15.
SUM(quantity) * SUM(unit_price)would give 6 x 329.97 = 1,979.82: every unit times every price. Multiplying before adding is the whole point of an iterator.
Why it exists
Harbor & Pine's Finance Controller wants net revenue built from quantity, unit_price and
discount_pct. The number she needs does not exist as a column: it is a product, per line, that
then has to be added. SUM can only add a column that is already there.
A spreadsheet user would add a helper column, fill it down, and sum it. An iterator is that helper column without the column: the multiplication happens in memory, for exactly the rows the cell is allowed to see, and nothing is stored in the model. That keeps the file smaller and the number alive: change the slicer and the rows being walked change with it.
The X functions are a family with one shape: SUMX, AVERAGEX, MINX, MAXX and COUNTX
differ only in how they collapse the per-row results. Learn one and you have learned all of them.
How it works under the hood
SUMX(table, expression) runs in two stages, and the order matters.
- Decide which rows exist. The filter context of the cell (row header, slicers,
CALCULATEfilters) shrinkstablefirst. ASUMXoverorder_itemsin the Lighting row of the matrix walks only Lighting lines. - Walk them. For each remaining row, evaluate the expression with that row as the row context, then combine the results.
Because the expression runs once per row, it can use columns of the row (order_items[quantity])
and, through a many-to-one relationship, columns of the row's parent (RELATED(products[unit_cost])).
It cannot reach down the other way without help: from a product you do not automatically see its
lines.
Context transition
That "without help" is the interesting case. Inside a calculated column or an iterator, a plain
SUM(order_items[line_total]) ignores the row you are on. The current product is a position, not
a filter, so nothing reaches order_items.
CALCULATE fixes this. When it runs inside a row context it takes every column of the current row
and turns them into filters, then evaluates its expression under those filters. This is context
transition. For product 135 it becomes product_id = 135, which flows down the relationship to
order_items the way any slicer would.
Figure 2
A calculated column on products: row context versus a filter
products
- product_id
- name
- category
product_id = 135
orders
- order_id
- channel
- status
order_items
- order_id
- product_id
- line_total
- A calculated column is evaluated once per row of
products. For product 135 the engine is standing on that row: a row context. SUM(order_items[line_total])asksorder_itemsfor its rows. Nothing has filtered it: a row context is not a filter and does not travel along relationships.- Wrap it in
CALCULATE. Context transition turns the current row into a filter,product_id = 135, and that filter flows down like any other.
Seeing it in numbers makes it stick: the same two formulas in a calculated column on products.
Figure 3
The same column, with and without CALCULATE
| product_id | name | bare_sum | with_calculate |
|---|---|---|---|
| 134 | Sable Linen Table Lamp | $6,516,344 | $51,423 |
| 135 | Ridge Ceramic Chandelier | $6,516,344 | $89,718 |
| 186 | Birch Brass Floor Mirror | $6,516,344 | $46,536 |
bare_sum = SUM(order_items[line_total]). Every product shows the sales of the whole store.with_calculate = CALCULATE(SUM(order_items[line_total])). Each product now sums only its own lines.
Choosing the table to walk
The table you hand to an iterator decides what one "item" is. AVERAGEX(order_items, ...) is an
average per order line. AVERAGEX(orders, [Sales]) is an average per order, which is what
"average order value" means: each order is visited, its own lines are summed through the context
transition, and those order totals are averaged. Same function, different table, a different
business question. Ask "what is one unit of this average?" before you type the first argument.
The same idea carries a weighted margin. Walk order_items, and for each line reach up to its
product with RELATED(products[unit_cost]). The cost is multiplied by that line's quantity, so
products that sell a lot count more. Averaging the margin of the 120 products would give every
product the same weight, which is not what the till saw.
Measures carry an invisible CALCULATE
A reference to a measure, [Sales], behaves as if it were wrapped in CALCULATE. That is why
SUMX(products, [Sales]) returns each product's own sales added up, while
SUMX(products, SUM(order_items[line_total])) repeats the store total once per product. The first
transitions the row into a filter; the second does not.
It also means iterators have a price. A measure inside a SUMX over a big table triggers a
transition per row, which is slow and, with a hundred-thousand-row table, noticeable. Iterate
over the small table (products, 120 rows) rather than the big one (order lines, over 30,000) when
the answer is the same.
Where to try it in Power BI Desktop
Use Home → New measure for an iterator and Table tools → New column for a calculated
column. To see what a row context does, write the same expression in both places and compare. The
DAX query view lets you run EVALUATE ADDCOLUMNS(products, "x", [Sales]) and read the result
as a table.
Common misconceptions
"SUM(quantity) * SUM(price) is the same as adding quantity times price"
Multiplying two totals multiplies every quantity by every price, not each line's own pair. For order 1004 it gives 1,979.82 instead of 376.15, and the gap grows with the table. Whenever a number is a product or ratio of two columns per row, it is an iterator.
"A row context filters the other tables"
It does not. Standing on a product row tells the formula which row's columns to read, nothing
more. The order_items table sees every line until something creates a filter. That something is
CALCULATE, or a measure reference.
"Iterators are always slower, so avoid them"
An iterator over a column expression (SUMX(order_items, quantity * unit_price)) is cheap: the
storage engine handles it. The slow pattern is a measure evaluated once per row of a large table.
Choose the smallest table that gives the right answer and keep the expression simple.
Check yourself
One question. Answering it ticks this lesson off on the map; it never locks a level.
Not quite. Being on a row only gives you that row's own columns. It does not filter other tables, so A cannot know which product it is standing on.Try another answer.
Not quite. It is the other way round. The bare SUM has nothing filtering order_items, and CALCULATE is what creates the filter.Try another answer.
Right. A has a row context but no filter, so it sums every line. B's CALCULATE converts the current row into product_id = 135, which flows to order_items.
Not quite. A calculated column sees no filter until CALCULATE (or a measure reference) converts the row into one. B does exactly that.Try another answer.
Lesson done: it is ticked on the map.