SUMMARIZECOLUMNS is the function Power BI uses to execute every visual query — and most practitioners never learn to write it deliberately. This lesson teaches you to design efficient many-to-one aggregations, add hierarchical subtotals with ROLLUPADDISSUBTOTAL, and build measures that cooperate with the VertiPaq engine instead of fighting it.

Picture this: you have a sales model with 50 million transaction rows, a product dimension, a customer dimension, a date table, and a territory hierarchy. Your report has a matrix visual with Product Category on rows, Region on columns, and four KPIs in the values area. The visual is taking eight seconds to render. Your users are complaining. You open DAX Studio and look at the query Power BI is generating under the hood — and you see SUMMARIZECOLUMNS staring back at you, doing a lot of heavy lifting you never explicitly wrote.
This is the function at the heart of how Power BI executes virtually every visual query. Understanding SUMMARIZECOLUMNS deeply — how it works, how to write it yourself, how to use it to structure your measures, and what trips people up in large models — is what separates practitioners who build fast, reliable reports from those who spend their afternoons fighting with slow visuals and mysterious blank rows.
By the end of this lesson, you'll be writing SUMMARIZECOLUMNS queries from scratch, designing measures specifically to perform well inside it, debugging common failures, and using it to aggregate across many-to-one relationships without the pitfalls that sink most intermediate DAX writers.
What you'll learn:
SUMMARIZECOLUMNS differs from SUMMARIZE and why Power BI prefers it for visual queriesSUMMARIZECOLUMNSSUMMARIZECOLUMNS queries directly in DAX Query ViewROLLUPADDISSUBTOTAL and NONVISUAL for subtotals and filter isolationSUMMARIZECOLUMNS instead of fighting it, including performance patterns for large data modelsYou should already be comfortable with DAX filter context and CALCULATE — if you need a refresher, see Understanding DAX: CALCULATE and Filter Context. You should also understand how relationships propagate filters in a Power BI model; DAX Relationships 101: How Power BI Filters Flow Across Tables and Why It Matters for Your Measures covers the mechanics you'll need here. Finally, familiarity with virtual tables and functions like ADDCOLUMNS and basic SUMMARIZE will help — DAX Virtual Tables in Practice: Using ADDCOLUMNS, SUMMARIZE, and GENERATEALL to Build Complex Aggregations Without Helper Tables is the right starting point if that's new territory.
SUMMARIZECOLUMNS is a table function. It returns a table of grouped values, much like a SQL GROUP BY, but it's deeply integrated with the DAX engine's storage-level optimization layer — the VertiPaq engine. When Power BI renders a visual, it constructs a SUMMARIZECOLUMNS query internally and sends it to the engine. That's not a coincidence. Microsoft designed SUMMARIZECOLUMNS specifically to be the query form that VertiPaq can optimize most aggressively.
The function signature looks like this:
SUMMARIZECOLUMNS(
<GroupBy_ColumnName1>,
<GroupBy_ColumnName2>, ...,
[<FilterTable1>], ...,
<Name1>, <Expression1>,
<Name2>, <Expression2>, ...
)
You provide grouping columns (from any table in the model), optional filter tables, and named measure expressions. The engine groups rows by the grouping columns, applies the filters, evaluates each expression inside the resulting filter context for each group, and returns the result as a table — omitting rows where all expressions return BLANK().
That last behavior — automatic blank-row removal — is one of the most important differences from SUMMARIZE. With SUMMARIZE, you get every combination of grouping keys whether or not they produce data. With SUMMARIZECOLUMNS, combinations that produce all-blank measure values are silently dropped. This is almost always what you want in a visual, but it can bite you when you're writing queries and expecting certain rows to appear.
Key insight: Power BI's visual rendering engine generates
SUMMARIZECOLUMNSqueries automatically. When you write measures that perform poorly or produce unexpected results in visuals, the root cause is almost always how those measures behave inside aSUMMARIZECOLUMNSevaluation context. Understanding the function is the same as understanding how your visuals work.
In most well-designed Power BI models, you have a star schema: a central fact table (like Sales) on the "many" side of relationships, and dimension tables (DimProduct, DimCustomer, DimDate, DimTerritory) on the "one" side. When you group by a column from a dimension table, you're doing a many-to-one aggregation — collapsing many fact rows per dimension key into a single summary row.
SUMMARIZECOLUMNS handles this elegantly. When you specify DimProduct[Category] as a grouping column, the engine automatically applies a filter on DimProduct that propagates through the relationship to Sales. You don't need to write that filter explicitly. This is relationship-aware grouping, and it's far more efficient than the equivalent written with SUMMARIZE inside ADDCOLUMNS.
Consider this query:
EVALUATE
SUMMARIZECOLUMNS(
DimProduct[Category],
DimTerritory[Region],
"Total Sales", [Total Sales Amount],
"Order Count", [Order Count],
"Avg Basket Size", [Avg Basket Size]
)
ORDER BY DimProduct[Category], DimTerritory[Region]
Where [Total Sales Amount], [Order Count], and [Avg Basket Size] are model measures. This query asks: "For every combination of Product Category and Region that has any sales, give me these three KPIs." The engine pushes that grouping all the way down to the VertiPaq column store. It doesn't materialize all rows from Sales and then group them in memory — it uses the compressed column dictionaries to aggregate directly.
Tip: When grouping by dimension columns rather than fact table columns,
SUMMARIZECOLUMNScan achieve what VertiPaq calls a "segment-level aggregation" — the calculation happens in highly compressed column segments without decompressing individual rows. This is orders of magnitude faster than row-by-row iteration for large tables.
The contrast with the naïve approach is stark. Newer DAX writers sometimes reach for ADDCOLUMNS(SUMMARIZE(...), ...) to build similar result sets. That pattern is slower because SUMMARIZE can materialize intermediate tables that don't benefit from the same storage-engine optimizations. When you're aggregating over a 50-million-row fact table, that difference matters enormously.
Power BI Desktop has a DAX Query View (the Query View icon in the left navigation bar — it looks like a grid with a magnifying glass). This is where you write and execute SUMMARIZECOLUMNS directly. It's an underused but powerful feature for validating measures before embedding them in visuals.
Let's build a realistic example. Suppose you have:
FactSales — transaction-level table with SalesAmount, UnitCost, OrderDate, ProductKey, CustomerKey, TerritoryKeyDimProduct — with ProductKey, ProductName, Subcategory, CategoryDimCustomer — with CustomerKey, CustomerName, Segment (Enterprise, SMB, Consumer)DimDate — with DateKey, Date, Year, Quarter, MonthDimTerritory — with TerritoryKey, Region, CountryYour measures:
Total Sales Amount =
SUM( FactSales[SalesAmount] )
Total Cost =
SUM( FactSales[UnitCost] )
Gross Profit =
[Total Sales Amount] - [Total Cost]
Gross Margin % =
DIVIDE( [Gross Profit], [Total Sales Amount] )
Now you want to produce a query-ready result table showing Category, Region, Year, and all four KPIs — filtered to a specific customer segment. Here's the full SUMMARIZECOLUMNS query:
EVALUATE
SUMMARIZECOLUMNS(
DimProduct[Category],
DimTerritory[Region],
DimDate[Year],
FILTER(
ALL( DimCustomer[Segment] ),
DimCustomer[Segment] = "Enterprise"
),
"Total Sales", [Total Sales Amount],
"Total Cost", [Total Cost],
"Gross Profit", [Gross Profit],
"Gross Margin %", [Gross Margin %]
)
ORDER BY
DimDate[Year] ASC,
DimProduct[Category] ASC,
DimTerritory[Region] ASC
A few things to notice:
The filter is a table, not a boolean. The FILTER(ALL(DimCustomer[Segment]), ...) pattern wraps the filter inside ALL() to remove any existing context on that column, then re-filters it down to just "Enterprise". This is analogous to how CALCULATE handles filter arguments. You can pass multiple filter tables to SUMMARIZECOLUMNS, and they stack as AND conditions.
The grouping columns come from dimension tables. DimProduct[Category], DimTerritory[Region], and DimDate[Year] are all on the "one" side of relationships to FactSales. The engine propagates their filters into the fact table automatically.
Blank rows are excluded. If a Category-Region-Year combination has no Enterprise sales, it won't appear in the result. If you need those rows (for a complete matrix), you need a different approach — more on that in the troubleshooting section.
Warning: Don't use
SUMMARIZECOLUMNSinside anotherSUMMARIZECOLUMNSor insideCALCULATE. It's designed as a top-level query function, not as a sub-expression in a measure. Using it inside a measure will throw an error at runtime. For building virtual tables inside measures, useSUMMARIZEwithADDCOLUMNS, or specific iterator functions.
One of SUMMARIZECOLUMNS's killer features is ROLLUPADDISSUBTOTAL. It tells the engine to compute subtotals for one or more grouping columns and add a boolean indicator column that flags whether a row is a subtotal row. This is how Power BI renders totals in matrix visuals without running a separate query.
Here's the pattern:
EVALUATE
SUMMARIZECOLUMNS(
ROLLUPADDISSUBTOTAL(
DimProduct[Category], "Is Category Total",
DimTerritory[Region], "Is Region Total"
),
DimDate[Year],
"Total Sales", [Total Sales Amount],
"Gross Profit", [Gross Profit]
)
ORDER BY
DimProduct[Category],
DimTerritory[Region],
DimDate[Year]
The engine returns rows for every Category-Region-Year combination, plus subtotal rows where [Is Category Total] is TRUE (aggregated across all categories for each Region-Year) and where [Is Region Total] is TRUE (aggregated across all regions for each Category-Year), plus a grand total row where both flags are TRUE. The measure expressions are evaluated in the appropriate filter context for each rollup level — you don't write any conditional logic in the measures themselves.
This is enormously useful when you're exporting DAX query results to downstream tools (Python, Excel, paginated reports) and need subtotal rows in the output. Instead of computing detail rows and then re-running summary queries, a single SUMMARIZECOLUMNS with ROLLUPADDISSUBTOTAL gives you the full hierarchical result set.
Tip: When you parse the output of a
ROLLUPADDISSUBTOTALquery in Python or Power Query, filter on the boolean columns to separate detail rows from subtotal rows. Subtotal rows haveTRUEin the correspondingIs X Totalcolumn andBLANKin the grouping column itself.
Sometimes you want to filter the data used in your measure calculations without making that filter column part of the grouping. This is where NONVISUAL comes in.
Consider this scenario: you want to show sales by Category and Region, but you want to restrict the fact data to only the current fiscal year — without Year appearing as a grouping column in your result. You could put the year filter directly in your measure using CALCULATE, but if you have multiple measures, duplicating that filter across all of them is messy and error-prone. NONVISUAL solves this cleanly at the query level:
EVALUATE
SUMMARIZECOLUMNS(
DimProduct[Category],
DimTerritory[Region],
NONVISUAL(
FILTER(
ALL( DimDate[Year] ),
DimDate[Year] = 2024
)
),
"Total Sales", [Total Sales Amount],
"Gross Profit", [Gross Profit],
"Gross Margin %", [Gross Margin %]
)
The NONVISUAL wrapper tells SUMMARIZECOLUMNS to apply the year filter to the measure calculations but not to include DimDate[Year] in the grouping columns. The result is a clean Category-Region table where the underlying data is scoped to 2024 — no Year column cluttering the output, no duplicated filter logic in each measure.
This distinction between "visual" and "non-visual" filters maps directly to how Power BI's internal query builder works. When a slicer on Year drives the report, Power BI generates that as a non-visual filter in the underlying SUMMARIZECOLUMNS query. When you drag Year onto a visual's rows or columns, it becomes a grouping column. Knowing this lets you reason about what the engine is actually doing when you adjust your report layout.
Writing the function correctly is only half the battle. The other half is designing your measures so they cooperate with the storage engine rather than forcing expensive formula-engine work. Here's where real performance differences emerge in large models.
Measures that resolve to a single column aggregation — SUM, COUNT, MIN, MAX, AVERAGE — are VertiPaq's bread and butter. When SUMMARIZECOLUMNS evaluates SUM(FactSales[SalesAmount]) grouped by DimProduct[Category], the engine can handle the entire calculation in the compressed column store. No row iteration in the formula engine at all.
Contrast that with a measure using SUMX over a complex row-by-row calculation:
-- This forces formula engine row iteration
Revenue After Discount (Slow) =
SUMX(
FactSales,
FactSales[UnitPrice] * FactSales[Quantity] * (1 - FactSales[DiscountRate])
)
For a 50-million-row table, that iterator touches every row. If this measure lives inside SUMMARIZECOLUMNS with five grouping columns, you're iterating 50 million rows for each combination — serially. You can learn more about when iterators are necessary vs. when they add unnecessary cost in DAX Iterators Explained: How SUMX, AVERAGEX, and MAXX Evaluate Row by Row to Solve Problems SUM Cannot.
The fix, when possible, is to pre-compute derived values as calculated columns on the fact table (when cardinality allows), or to restructure the measure to use additive base measures:
-- In the model, add a calculated column (if row count allows):
-- FactSales[NetRevenue] = FactSales[UnitPrice] * FactSales[Quantity] * (1 - FactSales[DiscountRate])
-- Then your measure becomes storage-engine-friendly:
Net Revenue =
SUM( FactSales[NetRevenue] )
Note: The calculated column approach trades model size (memory) for query speed. For a 50-million-row table, a single FLOAT column adds roughly 400MB of memory in an uncompressed worst case — but VertiPaq compression typically gets you 5-20x compression ratios for numeric columns with bounded value ranges. Profile your specific data before dismissing this approach.
One subtle performance issue inside SUMMARIZECOLUMNS is measure expressions that reference other measures multiple times. Each reference is a separate evaluation. Variables eliminate this by materializing the value once:
Gross Margin % =
VAR _Sales = [Total Sales Amount]
VAR _Profit = [Gross Profit]
RETURN
DIVIDE( _Profit, _Sales )
This is especially important for measures used as components in downstream measures. If [Gross Margin %] internally calls [Total Sales Amount] twice (once to compute profit and once to compute the ratio), and SUMMARIZECOLUMNS is evaluating it for 500 Category-Region-Year combinations, you've doubled the storage-engine scans unnecessarily. See DAX Variables in Practice: Using VAR and RETURN to Simplify Complex Measures for the full treatment of this pattern.
When a measure uses CALCULATE to modify filter context, it works correctly inside SUMMARIZECOLUMNS — but it interacts with the grouping in ways you need to understand. Each row in the SUMMARIZECOLUMNS result has a filter context that includes the current values of all grouping columns. Any CALCULATE inside your measure adds to or modifies that context.
This means that a measure like:
Sales YTD =
CALCULATE(
[Total Sales Amount],
DATESYTD( DimDate[Date] )
)
Will correctly compute year-to-date sales for each Category-Region-Year combination when evaluated inside SUMMARIZECOLUMNS, because DATESYTD modifies the date filter while the Category and Region filters from the grouping remain in place. The filter context stacking behavior you'd expect from CALCULATE works exactly the same way here. For a deep dive into time intelligence measures and their context interaction, see Time Intelligence in DAX: YTD, MTD, Previous Period, and Rolling Averages.
Key insight:
SUMMARIZECOLUMNScreates a filter context for each row it generates, identical to what you'd get if you put those column values in a row of a matrix visual. Your measures don't need to know they're insideSUMMARIZECOLUMNS— they just respond to filter context, which is always the right mental model for DAX measures.
Let's put this all together in a realistic scenario. You're building a quarterly business review dataset that will feed a Power BI report and be exported to Excel for the finance team. You need:
First, make sure you have these model measures defined:
Total Sales Amount =
SUM( FactSales[SalesAmount] )
Total Cost =
SUM( FactSales[UnitCost] )
Gross Profit =
[Total Sales Amount] - [Total Cost]
Gross Margin % =
VAR _Sales = [Total Sales Amount]
VAR _Cost = [Total Cost]
RETURN
DIVIDE( _Sales - _Cost, _Sales )
Sales Prior Year =
CALCULATE(
[Total Sales Amount],
SAMEPERIODLASTYEAR( DimDate[Date] )
)
YoY Growth % =
VAR _Current = [Total Sales Amount]
VAR _Prior = [Sales Prior Year]
RETURN
DIVIDE( _Current - _Prior, _Prior )
Now write the SUMMARIZECOLUMNS query:
EVALUATE
SUMMARIZECOLUMNS(
ROLLUPADDISSUBTOTAL(
DimTerritory[Region], "Is Region Total"
),
DimProduct[Category],
DimDate[Year],
FILTER(
ALL( DimDate[Year] ),
DimDate[Year] IN { 2023, 2024 }
),
FILTER(
ALL( DimCustomer[Segment] ),
DimCustomer[Segment] IN { "Enterprise", "SMB" }
),
"Total Sales", [Total Sales Amount],
"Gross Profit", [Gross Profit],
"Gross Margin %", [Gross Margin %],
"Sales Prior Year", [Sales Prior Year],
"YoY Growth %", [YoY Growth %]
)
ORDER BY
DimDate[Year] ASC,
DimTerritory[Region] ASC,
DimProduct[Category] ASC
Run this in DAX Query View and you'll get a result table with:
Is Region Total = FALSE)Is Region Total = TRUE and DimProduct[Category] = BLANK)CALCULATE filter per measureIf you're exporting this to Excel via a Power Automate flow or a Python script using the semantic-link library, this query produces exactly the shape you need — totals included, correctly calculated, in one pass.
Tip: When using DAX queries to feed downstream exports or paginated reports,
SUMMARIZECOLUMNSwithROLLUPADDISSUBTOTALis far more efficient than running multiple queries (one for detail, one for subtotals, one for grand total). The engine computes all rollup levels in a single storage-engine scan.
Work through this exercise in DAX Query View against your own model, or use the Adventure Works DW dataset (available from Microsoft's sample datasets).
Exercise: Regional Profitability Digest with Rollup
Step 1: Create the following measures in your model if they don't exist:
Total Sales Amount =
SUM( FactInternetSales[SalesAmount] )
Total Product Cost =
SUM( FactInternetSales[TotalProductCost] )
Gross Profit =
[Total Sales Amount] - [Total Product Cost]
Gross Margin % =
DIVIDE( [Gross Profit], [Total Sales Amount] )
Transaction Count =
COUNTROWS( FactInternetSales )
Avg Order Value =
DIVIDE( [Total Sales Amount], [Transaction Count] )
Step 2: Write a SUMMARIZECOLUMNS query that:
DimSalesTerritory[SalesTerritoryGroup] and DimProductCategory[EnglishProductCategoryName]ROLLUPADDISSUBTOTAL to SalesTerritoryGroupDimDate[CalendarYear] with NONVISUAL so year doesn't appear as a group)Step 3: Examine the result table. Identify which rows have Is SalesTerritoryGroup Total = TRUE. Verify that the gross margin percentage in subtotal rows is not a simple average of the detail rows — it should be the ratio of summed profit to summed sales (which is what your measure correctly computes using DIVIDE).
Step 4: Modify the query to add DimDate[CalendarYear] as a visible grouping column (remove the NONVISUAL wrapper). Observe how the result shape changes — Year is now a dimension in the output, and you get more granular rows.
Step 5: Add a second ROLLUPADDISSUBTOTAL entry for DimProductCategory[EnglishProductCategoryName]. Observe the additional subtotal rows that appear.
Expected challenge: In Step 3, if Avg Order Value returns subtotals that look like weighted averages (not straight averages), that's correct — your measure divides total sales by total transaction count for the subtotal group. This is the right business logic, and it happens automatically because SUMMARIZECOLUMNS evaluates your measure in the correct filter context for each row.
-- This will error at runtime:
My Measure =
SUMX(
SUMMARIZECOLUMNS( DimProduct[Category], "Sales", [Total Sales Amount] ),
[Sales]
)
SUMMARIZECOLUMNS is a query function, not a measure function. It cannot be used inside another DAX expression that's evaluated as a measure. Use SUMMARIZE with ADDCOLUMNS instead:
-- Correct approach:
My Measure =
SUMX(
ADDCOLUMNS(
SUMMARIZE( FactSales, DimProduct[Category] ),
"Sales", [Total Sales Amount]
),
[Sales]
)
When you pass a filter to SUMMARIZECOLUMNS, filtering on a fact table column behaves differently than filtering on a dimension column. A filter on FactSales[SalesChannel] applies directly to the fact table. A filter on DimCustomer[Segment] propagates through the relationship. If your model has multiple fact tables (like in a role-playing dimension setup), make sure your filter is on the right table to propagate correctly.
Warning: Filtering on a column that has no relationship path to your grouping columns will silently produce unexpected results —
SUMMARIZECOLUMNSwon't throw an error; it will just evaluate measures in a filter context that doesn't intersect with your grouping keys the way you expect. Always validate your filter logic in DAX Query View before embedding it in a report.
If your grouping column combinations produce no data (all measures return BLANK), those rows are excluded from the result. This is usually correct for visuals but breaks matrix headers when you need every row to show even with no data.
The fix is to generate the "spine" of combinations separately and left-join the measures. In DAX Query View, you can use NATURALLEFTOUTERJOIN or GENERATE for this, but in visuals you need the Show items with no data feature (right-click the field in the Values well).
In ROLLUPADDISSUBTOTAL rows, the grouping column that's being rolled up has a filter context of "all values" — equivalent to REMOVEFILTERS(DimTerritory[Region]). This means a measure that uses HASONEVALUE(DimTerritory[Region]) to branch its logic will take the "not one value" branch on subtotal rows. If you're building measures with conditional logic based on context, test them specifically against rollup rows.
For building context-aware measures that respond to whether a column is filtered, see Mastering DAX Information Functions: Building Smart Measures with HASONEVALUE, ISFILTERED, and ISCROSSFILTERED.
Every additional grouping column multiplies the number of rows the engine needs to compute. Grouping by DimProduct[Category] (4 values), DimTerritory[Region] (6 values), and DimDate[Year] (3 values) gives you at most 72 combinations — trivial. But add DimDate[Month] (12 values) and DimCustomer[Segment] (3 values) and you're at 2,592 combinations, each requiring measure evaluation. With expensive measures, this compounds quickly.
Profile your queries in DAX Studio using the Server Timings panel. Look at the ratio of Storage Engine (SE) time to Formula Engine (FE) time. High FE time means your measures are forcing row-by-row iteration that the SE can't optimize. Restructuring those measures to use simple aggregations — or pre-computing heavy calculations as calculated columns — is the path forward.
SUMMARIZECOLUMNS is the query function that Power BI lives inside. Learning to read it, write it, and design for it moves you from someone who writes DAX measures and hopes they're fast to someone who understands exactly what the engine will do with them.
Here's what you've covered:
SUMMARIZECOLUMNS queries in DAX Query ViewWhere to go next:
For reporting patterns that require complex many-to-many relationships instead of the clean many-to-one scenarios covered here, DAX for Many-to-Many Relationships and Complex Data Models covers the additional nuances you'll encounter. If your next challenge involves ranking and top-N analysis within the grouped output of SUMMARIZECOLUMNS, DAX Ranking Patterns in Practice: RANKX, TOPN, and Dense vs. Sparse Rankings Across Dynamic Filter Contexts is the natural next lesson. And for financial reporting models where the aggregation patterns are more complex — semi-additive measures, budget vs. actuals, P&L hierarchies — Advanced DAX Patterns for Financial Reporting: Mastering P&L, Balance Sheet, and Budget Models builds directly on the query-construction foundations you've developed here.