Wicked Smart Data
LearnArticlesAbout
Sign InSign Up
LearnArticlesAboutContact
Sign InSign Up
Wicked Smart Data

The go-to platform for professionals who want to master data, automation, and AI — from Excel fundamentals to cutting-edge machine learning.

Platform

  • Learning Paths
  • Articles
  • About
  • Contact

Connect

  • Contact Us
  • RSS Feed

© 2026 Wicked Smart Data. All rights reserved.

Privacy PolicyTerms of Service
All Articles

Optimizing Power BI Report Performance: Query Reduction, Aggregations, and DirectQuery Tuning

Power BI🔥 Expert31 min readJun 28, 2026Updated Jun 28, 2026
Table of Contents
  • Introduction
  • Prerequisites
  • Understanding the Query Pipeline: Where Time Actually Goes
  • Using Performance Analyzer: Your First Diagnostic Tool
  • Reading DAX Studio Output
  • Query Reduction Settings: Reducing the Noise
  • Report-Level: Reduce Number of Queries Sent
  • Slicer-Level Settings
  • Aggregations: The Composite Model Superpower
  • Why Aggregations Are Architecturally Important
  • Building an Aggregation Table
  • Configuring the Aggregation

Optimizing Power BI Report Performance: Query Reduction, Aggregations, and DirectQuery Tuning

Introduction

You've built a Power BI report that tells a genuinely compelling story with your data. The visuals are clean, the DAX is correct, and the stakeholders love the design. Then someone opens it on a Monday morning, clicks a slicer, and waits. And waits. A spinner rotates for six seconds, the page redraws, they click something else, wait again. By the end of the meeting they're frustrated, the data team is embarrassed, and someone has sent a Slack message asking if "the report is broken."

Slow Power BI reports are not just an aesthetic problem — they erode trust in the data platform. When a report feels sluggish, users stop using it, start maintaining their own Excel files, and the organization's investment in a centralized analytics stack quietly evaporates. Performance optimization in Power BI is therefore not a luxury or a finishing touch: it is a core competency for any data professional building production-grade reports.

By the end of this lesson, you will understand why Power BI queries slow down, not just which settings to toggle. You will learn how the query reduction settings actually reduce engine load, how aggregations work at the VertiPaq and DirectQuery boundary, and how to tune DirectQuery source queries so your database is working smarter, not harder. This is deep, architectural content — bring coffee.

What you'll learn:

  • How Power BI's query pipeline works end-to-end, from user interaction to data source response
  • How to use query reduction settings to minimize the number of DAX and SQL queries fired per interaction
  • How composite model aggregations intercept DirectQuery queries and serve results from in-memory cache
  • How to tune DirectQuery behavior through query folding, connection string parameters, and source-side optimization
  • How to use Performance Analyzer and DAX Studio to diagnose bottlenecks and validate improvements

Prerequisites

This lesson assumes you are comfortable with:

  • DAX fundamentals including CALCULATE, FILTER, and iterator functions
  • The difference between Import mode, DirectQuery mode, and Composite models
  • Basic understanding of relational databases and SQL query concepts
  • Familiarity with Power BI Desktop and the Power Query editor

If you have not yet worked with DirectQuery or composite models in a production context, you will still benefit from the conceptual sections, but the hands-on exercise will make more sense after you have at least experimented with those modes.


Understanding the Query Pipeline: Where Time Actually Goes

Before you optimize anything, you need a mental model of what happens between a user clicking a slicer and a visual updating. The naive assumption is that Power BI "just runs a query," but the reality involves at least four distinct processing stages, and the bottleneck could be in any of them.

Stage 1: DAX Query Generation

When a user interacts with a visual — filtering, slicing, drilling down — the Power BI report engine generates one DAX query per visual on the page that responds to the interaction. If your report page has twelve visuals and a user clicks a slicer, Power BI may fire twelve DAX queries simultaneously (or in rapid succession depending on the query queue). Each visual generates its own independent DAX query based on its fields, measures, and the current filter context.

This is an important architectural insight: every visual is independently queried. They do not share a query result. If you have a card showing total revenue, a bar chart showing revenue by region, and a line chart showing revenue over time, those are three separate DAX queries — even though they all involve the same measure. This is where query reduction begins to matter.

Stage 2: DAX Engine Evaluation (Formula Engine + Storage Engine)

The DAX query is processed by the Analysis Services engine that powers Power BI. The Formula Engine (FE) interprets the DAX, plans the execution, and coordinates with the Storage Engine (SE). The Storage Engine is the part that actually reads data — from VertiPaq in Import mode, or by generating native source queries in DirectQuery mode.

The Formula Engine is single-threaded. The Storage Engine is multi-threaded. This means complex DAX with many nested iterators that can't be pushed down to the Storage Engine will create a bottleneck at the single-threaded Formula Engine. When you see high "FE" time in DAX Studio, that's what's happening — your DAX is doing work that should be offloaded to the source.

Stage 3: Storage Engine Query (SE Query)

In Import mode, the SE reads from the compressed VertiPaq in-memory column store. SE queries are generally fast — VertiPaq is highly optimized for aggregation queries on compressed columnar data. The SE returns a datacache to the FE.

In DirectQuery mode, the SE does not read VertiPaq. Instead, it translates the request into a native SQL query (or whatever the source dialect supports) and sends it to the source database. The round-trip time now includes network latency, query compilation at the database, disk I/O or buffer pool reads, and the return trip of the result set. This is why DirectQuery is architecturally slower than Import for most analytical queries.

Stage 4: Visual Rendering

Once the DAX engine returns results, Power BI renders the visual. Rendering is rarely the bottleneck unless you're working with extremely large result sets or custom visuals with heavy JavaScript rendering logic. Most of the time, if a visual feels slow, the problem is in stages 1 through 3.

Key insight: When you're diagnosing a slow report, you need to identify which stage is slow. A report that fires twenty queries but each resolves in 50ms is a different problem than a report that fires two queries where each takes eight seconds. The solutions are completely different.


Using Performance Analyzer: Your First Diagnostic Tool

Before changing anything, measure. Power BI Desktop includes Performance Analyzer (View ribbon, then "Performance Analyzer" button), which captures timing data for every visual on a page when you interact with it.

Start a recording, then click "Refresh visuals" to get a baseline load time for every visual on the page. Then interact with a slicer or filter. For each visual, Performance Analyzer shows three numbers:

  • DAX query: The time the engine spent executing the DAX (including any SE queries to the source)
  • Other: Rendering time and other overhead
  • Copy query: A button that copies the DAX query to your clipboard

The "DAX query" time is your primary optimization target. Click "Copy query" on your slowest visual and paste it into DAX Studio — this is where real diagnostic work begins.

Reading DAX Studio Output

In DAX Studio (a free, essential external tool), paste your query and run it with "Server Timings" enabled. You will see:

  • Total duration: Wall-clock time
  • FE time: Formula Engine time
  • SE CPU: Storage Engine CPU time (can exceed total due to parallelism)
  • SE queries: Number of Storage Engine queries generated
  • SE cache hits: How many SE queries were served from cache versus going to source

A high ratio of FE time to total time suggests your DAX is not efficiently pushing work to the storage engine. High SE query counts combined with high individual SE durations (in DirectQuery) suggests the source database is the bottleneck.

Warning: Performance Analyzer timings in Desktop include cache effects. A measure that runs fast in Performance Analyzer might be slow for the first user of the day when the cache is cold. Always test with cleared caches. In DAX Studio, you can set the "Clear Cache Before Each Run" option.


Query Reduction Settings: Reducing the Noise

Query reduction settings control how many DAX queries Power BI fires in response to user interactions. They are not about making individual queries faster — they are about reducing the total number of queries. On a slow DirectQuery source, the difference between firing one query per slicer selection versus firing one query per checkbox click in a multi-select slicer can be the difference between a usable report and an unusable one.

Access these settings in Power BI Desktop via File → Options and Settings → Options → Report Settings (for document-level settings) or through individual visual and slicer configuration.

Report-Level: Reduce Number of Queries Sent

Under Options → Current File → Query Reduction, you will find three key settings:

1. "Add an 'Apply' button to each slicer to reduce queries"

When enabled, every slicer in the report gets an Apply button. The report engine does not fire any queries while the user is selecting slicer values — it waits until they click Apply. This is transformative for multi-select slicers over DirectQuery sources.

Consider a date slicer where users routinely select a date range by moving two handles. Without Apply, every movement of either handle fires a query. A user repositioning the date range might inadvertently trigger 15-20 queries during the dragging gesture. With Apply, they get one query when they're done. On a DirectQuery source where each query takes two seconds, that's the difference between 30 seconds of lag and a 2-second response after a deliberate interaction.

2. "Add an 'Apply' button to all basic filters to reduce queries"

The same principle applied to the Filters pane. Without this, clicking checkboxes in the Filters pane fires a query per click. With this, users click multiple filter values and then apply them as a batch.

3. "Reduce the number of queries sent by disabling cross-highlighting"

Cross-highlighting is the behavior where clicking a bar in a bar chart highlights related data in other visuals (showing highlighted versus non-highlighted portions). It is visually beautiful but mechanically expensive: it requires Power BI to send additional queries that contain the full dataset plus a separate filtered dataset for every other visual on the page.

Disabling cross-highlighting means clicks on visuals filter other visuals cleanly (cross-filtering) rather than highlighting. The visual experience changes slightly — instead of seeing highlighted versus non-highlighted segments, visuals simply filter — but you eliminate a substantial category of expensive queries.

Tip: You can disable cross-highlighting globally here, or you can disable it per-visual by selecting a visual, going to Format → Edit Interactions, and setting the interaction type to "filter" instead of "highlight" for specific visual pairs. This gives you surgical control when you want cross-highlighting in some places but not others.

Slicer-Level Settings

Individual slicers have a setting called "Single select" that — when combined with the slicer type — can reduce query firing. However, the more powerful slicer-level performance control is choosing the right slicer type for your data.

A dropdown slicer with the search function enabled fires queries lazily (only when the user clicks the dropdown and only when they interact). A list slicer with many values is more likely to trigger frequent queries because all values are visible and interactable at all times.

For high-cardinality dimensions like customer name or product SKU, always use a dropdown slicer or a search-enabled slicer. A list slicer showing 50,000 customer names is not just a performance problem — it's a usability problem.


Aggregations: The Composite Model Superpower

Aggregations are the most powerful performance optimization available in Power BI for DirectQuery scenarios, and they are also the most underused — primarily because they require careful setup and a solid understanding of how the engine decides to use them.

The core idea is simple: you maintain a pre-aggregated summary table in Import mode alongside a DirectQuery fact table. When a query can be answered by the aggregated summary, Power BI serves it from the fast in-memory VertiPaq store. When a query needs detail-level data that only exists in DirectQuery, it goes to the source. The report works in both modes transparently — users have no idea whether they're getting in-memory or live query results.

Why Aggregations Are Architecturally Important

In a typical enterprise Power BI scenario, a fact table might contain 500 million rows of transactional sales data in a SQL Server or Synapse Analytics database. A DirectQuery query aggregating that to monthly revenue by product category might scan hundreds of millions of rows even with good indexing, taking 8-15 seconds. But a pre-aggregated table containing revenue by month by product category might have only a few hundred thousand rows and live entirely in memory.

The insight is that most Power BI report interactions are at an aggregated level. Users look at total sales by region, revenue by quarter, units sold by product family. They drill to detail only occasionally — and even then, they're usually filtering to a specific region, time period, or product before drilling. The aggregation layer handles 80-90% of interactions without touching the database.

Building an Aggregation Table

Let's work through a concrete example. Suppose you have a DirectQuery connection to a SQL Server database with a FactSales table containing 200 million rows with columns: SaleDate, ProductKey, CustomerKey, RegionKey, SalesAmount, UnitsSold, CostAmount.

Step one is creating an aggregated table. You can do this in Power Query within Power BI, or you can create a view or materialized table in the source database (the database approach is better for large tables — Power Query aggregation at query time defeats the purpose).

In your source database, create a view like this:

CREATE VIEW dbo.FactSales_Agg_MonthProductRegion AS
SELECT
    YEAR(SaleDate) AS SaleYear,
    MONTH(SaleDate) AS SaleMonth,
    ProductKey,
    RegionKey,
    SUM(SalesAmount)  AS TotalSalesAmount,
    SUM(UnitsSold)    AS TotalUnitsSold,
    SUM(CostAmount)   AS TotalCostAmount,
    COUNT(*)          AS RowCount
FROM dbo.FactSales
GROUP BY
    YEAR(SaleDate),
    MONTH(SaleDate),
    ProductKey,
    RegionKey;

Import this view into Power BI as an Import mode table. This might be 500,000 rows instead of 200 million — a 400x reduction in size.

Critical: The aggregation table must be in Import mode. The DirectQuery fact table stays in DirectQuery. This creates a composite model — a semantic model that combines both storage modes.

Configuring the Aggregation

In Power BI Desktop, with both tables present, right-click the aggregation table in the Fields pane and select "Manage aggregations." A dialog appears where you map each column of the aggregation table to the corresponding aggregation function and source column from the DirectQuery table.

For our example, the mapping would be:

Aggregation Table Column Summarization Detail Table Detail Column
TotalSalesAmount Sum FactSales SalesAmount
TotalUnitsSold Sum FactSales UnitsSold
TotalCostAmount Sum FactSales CostAmount
RowCount Count rows FactSales (any)
SaleYear Group by FactSales SaleDate*
SaleMonth Group by FactSales SaleDate*
ProductKey Group by FactSales ProductKey
RegionKey Group by FactSales RegionKey

*Note: The date granularity mismatch (year/month in the aggregation vs. full date in the fact) is intentional — this is what defines the aggregation boundary.

After configuring this, set the aggregation table to be hidden from report view. Users should never see it directly — it operates transparently behind the scenes.

How the Aggregation Hit/Miss Logic Works

The engine decides whether to use the aggregation table based on a rule: if every column referenced in the DAX query maps to the aggregation table (either through group-by columns or aggregated columns), the aggregation is used. If any column in the query requires detail-level data not present in the aggregation table, the engine falls through to DirectQuery.

This means the design of your aggregation determines its effective coverage. If your aggregation groups by Year and Month but a visual tries to analyze sales by day-of-week, that's a miss — day-of-week is not in the aggregation, so the query goes to DirectQuery. If a visual tries to analyze sales by CustomerKey, that's also a miss — CustomerKey is not a group-by column in our aggregation.

This is why aggregation design is a strategic exercise, not a mechanical one. You need to analyze your actual query patterns — what dimensions and at what granularity do your users most commonly analyze — and design aggregations that cover those patterns.

Tip: You can have multiple aggregation tables at different granularities. A year-level aggregation for executive dashboards, a month-product-region aggregation for operational reporting, and a week-customer aggregation for customer analytics. Each covers different query patterns. The engine evaluates them in order from highest to lowest granularity and uses the first one that satisfies the query.

Verifying Aggregation Hits

In DAX Studio, run your measure query with "Server Timings" enabled. In the SE queries section, if you see queries hitting the aggregation table (they'll reference the aggregation table's underlying VertiPaq data), you have an aggregation hit. If you see queries going to the DirectQuery source, you have a miss.

Performance Analyzer in Desktop is less granular for this diagnosis — it will show fast query times for aggregation hits without explaining why. DAX Studio is the definitive tool here.


DirectQuery Tuning: Getting the Most From Your Source

When aggregations can't cover a query — because the user has drilled to detail, applied a highly specific filter, or is exploring data at a granularity below your lowest aggregation — the query goes to DirectQuery. Your job at that point is to make sure the DirectQuery path is as fast as possible.

Understanding Query Folding

Query folding is the process by which Power Query (M) transformations are translated into native source queries rather than being executed by the M engine after data retrieval. In DirectQuery mode, query folding is not optional — it is essential. If transformations do not fold, they are executed by the M engine row-by-row after data is retrieved, which for any non-trivial dataset is catastrophically slow.

Check whether your steps fold by right-clicking each applied step in Power Query Editor. If you see "View Native Query" and can click it to see the generated SQL, that step folds. If the option is grayed out, that step does not fold, and everything after it in the query chain won't fold either.

Common transformation killers for query folding:

  • Custom columns with complex M expressions that the connector cannot translate to SQL
  • Merging tables from different data sources — cross-source merges cannot fold because there is no unified source to fold to
  • List.Contains() or Table.Contains() operations, which many connectors cannot translate
  • Adding an index column — nearly universally breaks folding

When you find a non-folding step in a DirectQuery query, you have three options:

  1. Rewrite the transformation to use operations the connector can translate (often possible by breaking complex logic into simpler steps)
  2. Push the transformation to the source by creating a view or stored procedure in the database that pre-applies the logic
  3. Accept the non-folding behavior — only acceptable if it applies to a very small result set

Warning: In DirectQuery mode, a non-folding transformation that applies to a 100-million-row fact table will attempt to retrieve all 100 million rows into the M engine before applying the filter. This will almost certainly time out, crash Desktop, or be stopped by Power BI service's query timeout limits. Never let non-folding transformations sit in a DirectQuery query chain without explicitly testing them with a representative data volume.

Connection String Optimization

Power BI's DirectQuery connectors send queries to your source database, but you have some control over how that connection behaves.

Query timeout settings: In Power BI Service, dataset settings allow you to configure DirectQuery timeouts. The default is 225 seconds (3.75 minutes). For interactive reporting, if a query takes more than 30 seconds, users have already given up — consider setting a tighter timeout and optimizing source queries to meet it rather than letting slow queries run to completion.

Connection parallelism: Power BI Desktop has a setting (Options → DirectQuery → "Maximum connections per data source") that controls how many simultaneous connections it makes. The default is 10. For sources that handle high parallelism well (like Synapse Analytics or BigQuery), increasing this can reduce overall wait time when many SE queries are fired simultaneously. For sources with limited connection pool capacity, decreasing it prevents connection exhaustion.

Reducing result set size: DAX measures applied over DirectQuery will often generate SQL with GROUP BY on all the dimension columns involved in a visual. Make sure your fact table's relevant columns are indexed appropriately. For SQL Server and Azure SQL: SaleDate, ProductKey, RegionKey, and similar foreign key columns used in analysis should have non-clustered indexes or be part of a covering index strategy for the most common aggregation patterns.

Writing DirectQuery-Optimized DAX

Not all DAX is created equal in DirectQuery mode. Certain patterns translate efficiently to SQL; others generate SQL that is semantically correct but operationally terrible.

Pattern 1: Use simple aggregation measures, not iterator-based measures where avoidable

A measure like this:

Total Revenue =
SUM(FactSales[SalesAmount])

Translates cleanly to:

SELECT SUM(SalesAmount) FROM FactSales WHERE [filter conditions]

A measure like this:

Total Revenue Complex =
SUMX(
    FactSales,
    FactSales[UnitsSold] * RELATED(DimProduct[UnitPrice])
)

Is harder to fold efficiently because it involves a row-by-row iterator that accesses a related table per row. In Import mode the VertiPaq engine handles this efficiently because related table lookups are fast column operations. In DirectQuery, this generates a query that joins two tables and computes a row-level multiplication, which may be fine depending on the source but introduces complexity.

Pattern 2: Avoid CALCULATE with complex filter predicates in DirectQuery

Southeast Revenue =
CALCULATE(
    SUM(FactSales[SalesAmount]),
    DimRegion[RegionName] = "Southeast"
)

This is fine — it translates to a WHERE clause on the region name.

YTD Revenue =
CALCULATE(
    SUM(FactSales[SalesAmount]),
    DATESYTD(DimDate[Date])
)

This is more complex because DATESYTD returns a table of dates, which becomes an IN clause or a range filter in SQL depending on how the engine handles it. It's not necessarily slow, but on billion-row tables, DAX time intelligence functions create large IN lists that can stress query plans. An alternative is pushing YTD logic to the source as a computed column or view.

Pattern 3: Be careful with DISTINCTCOUNT in DirectQuery

Unique Customers = DISTINCTCOUNT(FactSales[CustomerKey])

In SQL this becomes COUNT(DISTINCT CustomerKey), which on very large tables requires a full scan of the column or a specific index. It's not a bad pattern, but if you see DISTINCTCOUNT measures being consistently slow, consider whether the source table has appropriate indexing for that column or whether the aggregation can be pre-computed.

Source-Side Optimizations

Power BI DirectQuery is ultimately a SQL-generator that sends queries to your source. The source needs to be ready to handle those queries efficiently. This is where your database administration knowledge matters as much as your Power BI knowledge.

Columnstore indexes for analytical workloads: If your DirectQuery source is SQL Server or Azure SQL, and you're running aggregation queries over large fact tables, clustered or non-clustered columnstore indexes are transformative. A columnstore index on a 200-million-row fact table can reduce the time for a GROUP BY aggregation query from 15 seconds to under 1 second because the engine reads only the columns needed and uses batch mode execution.

Create a non-clustered columnstore index covering the columns Power BI commonly queries:

CREATE NONCLUSTERED COLUMNSTORE INDEX NCCI_FactSales_BI
ON dbo.FactSales (
    SaleDate,
    ProductKey,
    RegionKey,
    CustomerKey,
    SalesAmount,
    UnitsSold,
    CostAmount
);

This does not need to include all columns in the table — just those referenced by Power BI's DAX-to-SQL generated queries.

Query hints via connection string: In certain Power BI connector configurations you can pass additional query options or hints. For Azure Synapse Analytics, for example, you might specify a resource class hint. For SQL Server, you can use stored procedures instead of direct table queries in some scenarios to inject query hints.

Source query monitoring: Enable Query Store on SQL Server/Azure SQL to capture the queries Power BI is generating. Filter Query Store for queries from the Power BI service's service principal or user credentials. Analyze the most expensive queries by total duration or CPU and optimize those specifically. This is the data-driven approach to source-side optimization — instead of guessing which queries are slow, you measure which queries are actually being generated and which are most expensive.


Advanced: Dual Storage Mode and Its Strategic Use

Composite models support a "Dual" storage mode for dimension tables. A table in Dual mode is stored both in VertiPaq (Import) and is also queryable via DirectQuery. Power BI automatically chooses which storage to use based on context.

When a query involves only Import-mode tables or Dual-mode tables in a context that doesn't require live data, VertiPaq is used. When a query involves a DirectQuery fact table, the Dual-mode dimension tables join with the DirectQuery fact table using DirectQuery — but without the overhead of a separate round-trip query for the dimension data, because the engine can use the in-memory dimension data to generate more efficient SQL.

The practical benefit: if you set your dimension tables (DimProduct, DimRegion, DimDate, DimCustomer) to Dual mode, then when an aggregation miss forces a DirectQuery query, the joins to dimension tables happen in SQL against the source efficiently (the engine can push dimension filter predicates into the WHERE clause of the fact table query rather than retrieving dimension keys first and then filtering).

Configure storage mode by selecting a table in Model view, opening the Properties pane, and setting "Storage mode" to "Dual."

Warning: Dual mode tables are refreshed on Import mode refresh schedule but are also queried live when part of a DirectQuery query chain. Make sure the dimension tables in your source are reasonably stable — if DimProduct changes frequently and your Import refresh is only once per day, there may be a period where the in-memory dimension data is out of sync with the fact table. For most dimension tables this is acceptable; for rapidly changing tables, consider keeping them in DirectQuery only.


Visual-Level Optimization Strategies

Beyond the engine-level optimizations, choices you make about how you build visuals directly impact query performance.

The Cost of High-Cardinality Axes

A bar chart with 10 categories fires a DAX query that returns 10 rows. A bar chart with 5,000 categories fires a DAX query that returns 5,000 rows — not just slower to execute, but slower to render, and likely useless from a UX perspective. Never put high-cardinality dimensions directly on a visual axis without a parent grouping or Top N filter.

The proper pattern for high-cardinality dimensions is a Top N filter combined with a "Relative to" ranking measure:

Revenue Rank =
RANKX(
    ALL(DimProduct[ProductName]),
    [Total Revenue],
    ,
    DESC,
    Dense
)

Then apply a visual-level filter: Revenue Rank is less than or equal to 10. This gives a Top 10 chart that is both performant and meaningful.

The Table and Matrix Problem

Tables and matrices in Power BI are some of the most expensive visuals to query because they request detail-level data rather than aggregated results. A matrix showing revenue by product by region by quarter might generate a DAX query returning tens of thousands of cells. Each cell requires an aggregation at the intersection of three dimensions.

For tables showing individual row data in DirectQuery mode, the engine must retrieve those rows from the source. Even with DirectQuery, Power BI applies a row limit (usually 1 million rows). However, displaying a table with 100,000 rows is not a useful report visual — it's a data export. If users need raw data, give them a "Export Data" button or a paginated report, not a visual table.

Optimize matrices by: limiting the number of dimensions shown simultaneously, using conditional visibility to hide detail rows until explicitly expanded, and ensuring the measures used in the matrix are simple aggregations rather than complex iterators.

Minimize the Number of Visuals Per Page

Every visual that responds to a filter interaction generates a DAX query. A page with 15 visuals generates up to 15 queries per user interaction. Consider whether every visual needs to be visible at all times.

Techniques to reduce visual count without reducing information density:

  • Tooltip pages: Instead of showing 3 supporting visuals alongside a main chart, put the supporting detail in a tooltip page that appears on hover. The tooltip page queries only fire when the user hovers, not on every filter interaction.
  • Drillthrough pages: Move detailed breakdowns to drillthrough pages. Users navigate explicitly to them; they don't query on every slicer interaction.
  • Bookmarks and buttons: Show one set of visuals at a time using bookmarks controlled by buttons. Users explicitly switch views rather than seeing everything simultaneously.

Hands-On Exercise

For this exercise, you will need Power BI Desktop, DAX Studio (free download from daxstudio.org), and ideally access to a SQL Server or Azure SQL database with a large fact table. If you don't have a large source database, use the Adventure Works DW 2019 sample database, which has approximately 12 million fact rows in FactInternetSales and related tables — large enough to demonstrate the concepts.

Part 1: Baseline and Diagnosis

  1. Create a DirectQuery connection to FactInternetSales and the relevant dimension tables (DimProduct, DimDate, DimSalesTerritory).
  2. Build a report page with: a slicer on DimDate[CalendarYear], a bar chart showing TotalSalesAmount by DimProduct[EnglishProductSubcategoryName], a line chart showing TotalSalesAmount by DimDate[MonthNumberOfYear], a card showing total TotalSalesAmount.
  3. Open Performance Analyzer (View → Performance Analyzer → Start Recording → Refresh Visuals).
  4. Click a slicer value and record the timing for each visual.
  5. Copy the slowest query to DAX Studio and run it with Server Timings enabled. Record FE time, SE time, and SE query count.

Part 2: Query Reduction

  1. Open Options → Current File → Query Reduction.
  2. Enable "Add an 'Apply' button to each slicer."
  3. Enable "Disable cross-highlighting."
  4. Repeat the Performance Analyzer test and observe whether total query count has changed.

The key metric here is not just query duration but how many queries fire per interaction. With the Apply button, clicking inside the slicer list should produce zero queries until you click Apply.

Part 3: Build and Configure an Aggregation

  1. In your source database, create an aggregated view:
CREATE VIEW dbo.FactInternetSales_Agg AS
SELECT
    YEAR(OrderDate)    AS OrderYear,
    MONTH(OrderDate)   AS OrderMonth,
    ProductKey,
    SalesTerritoryKey,
    SUM(SalesAmount)   AS TotalSalesAmount,
    SUM(OrderQuantity) AS TotalOrderQuantity,
    COUNT(*)           AS RowCount
FROM dbo.FactInternetSales
GROUP BY
    YEAR(OrderDate),
    MONTH(OrderDate),
    ProductKey,
    SalesTerritoryKey;
  1. In Power BI Desktop, add this view as a new table in Import mode (change the storage mode in the Properties pane after adding).
  2. Right-click the aggregation table in Fields → Manage Aggregations.
  3. Configure the mappings for TotalSalesAmount (Sum → FactInternetSales → SalesAmount), TotalOrderQuantity, RowCount, and the group-by columns.
  4. Hide the aggregation table from report view.
  5. Repeat the Performance Analyzer test. The bar chart (which groups by product subcategory and can be satisfied by the aggregation) and line chart (which groups by month) should now show dramatically lower times. The card showing total should also be faster.

Part 4: Verify Aggregation Hits in DAX Studio

  1. Copy the bar chart DAX query from Performance Analyzer.
  2. Run it in DAX Studio with Server Timings.
  3. In the SE Queries tab, observe whether the query is hitting VertiPaq (aggregation hit) or the DirectQuery source (aggregation miss).
  4. Now modify the bar chart to show data at a lower granularity not in your aggregation — for example, group by DimDate[FullDateAlternateKey] (individual day) instead of month. Re-run and observe the aggregation miss: the query should now go to DirectQuery.

Common Mistakes & Troubleshooting

Mistake 1: Aggregations configured but never hitting

Symptom: You've set up aggregation mappings but DAX Studio shows all queries going to DirectQuery.

Cause: The most common cause is a query that includes a column or granularity not covered by the aggregation. Even one dimension in a visual that isn't in your aggregation group-by columns causes a miss.

Fix: Add the Server Timings output to DAX Studio and inspect the SE query. Look at what columns are being grouped or filtered. If CustomerKey is in a slicer and CustomerKey isn't in your aggregation table, every slicer selection on customers will miss. Either add CustomerKey to the aggregation (which increases its size) or accept that customer-level analysis will go to DirectQuery.

Mistake 2: Query folding breaks after adding a "harmless" transformation step

Symptom: A DirectQuery table that was performing well suddenly becomes extremely slow after a Power Query change.

Cause: A new transformation step broke the query folding chain. Everything after a non-folding step also doesn't fold.

Fix: Open Power Query Editor, right-click each step after the new one and check "View Native Query." Find where the folding breaks. Remove or rewrite the breaking step. Consider moving the transformation to the source as a view column.

Mistake 3: Import-mode aggregation table is outdated

Symptom: Numbers in a dashboard are slightly off compared to the database, particularly for recent data. The discrepancy is more pronounced when the report uses the aggregation path versus the DirectQuery path.

Cause: The aggregation table was refreshed several hours ago. The DirectQuery fact table reflects real-time data. A query that hits the aggregation returns totals as of the last refresh; a query that misses the aggregation returns current live data.

Fix: For real-time accuracy requirements, increase the refresh frequency of the aggregation table, or consider whether the aggregation is appropriate for that specific visual. Some visuals with strict real-time requirements should be forced to DirectQuery by using a column or filter dimension that intentionally causes an aggregation miss.

Mistake 4: Too many visuals with "Disable cross-highlighting" unchecked

Symptom: After a slicer interaction, a 10-second delay before the page settles even though individual visuals look fast.

Cause: Cross-highlighting fires additional DAX queries. With 8 visuals and cross-highlighting enabled, a single click triggers up to 16 queries (8 filtered + 8 highlighting queries).

Fix: Disable cross-highlighting globally or per-visual. Use the Edit Interactions dialog to set interactions to "Filter" type between specific visual pairs.

Mistake 5: Placing complex measures in a table visual in DirectQuery mode

Symptom: A table visual with 5 measures and 1,000 visible rows takes 30+ seconds to render.

Cause: A table visual with N rows and M measures generates a DAX query that must produce N×M cells. In DirectQuery, this requires a source query that retrieves N rows' worth of detail, then applies M measure calculations.

Fix: Limit table visuals to aggregated data. Add Top N filters. Paginate results. If users genuinely need 1,000+ rows of detail data, switch to paginated reports (Power BI Report Builder), which are specifically designed for tabular data retrieval and handle large result sets with proper pagination.


Summary & Next Steps

Performance optimization in Power BI is fundamentally about understanding where time is spent and systematically eliminating waste at each layer. The mental model to carry with you:

  1. Reduce the number of queries with query reduction settings and thoughtful page design (fewer visuals, drillthrough, tooltips)
  2. Serve as many queries as possible from memory using aggregations in composite models
  3. Make DirectQuery queries as efficient as possible through query folding, source-side indexing, and DirectQuery-friendly DAX
  4. Measure everything before and after changes using Performance Analyzer and DAX Studio

The trap that most Power BI developers fall into is optimizing by intuition — "this visual looks complex, let me simplify it." Real optimization is empirical: you measure, you find the actual bottleneck, you fix the actual bottleneck, you measure again.

A report that was taking 15 seconds per interaction can routinely be brought to 1-2 seconds through the techniques in this lesson without changing any underlying data or business logic. The data is the same; the architecture of how you access it is what changes.

For your next learning steps, consider:

  • Power BI Premium and Incremental Refresh: Incremental refresh reduces the cost of dataset refresh for large Import tables, which affects how practical large aggregation tables are in production
  • Calculation Groups in Tabular Editor: Calculation groups can consolidate time intelligence measures in ways that generate more efficient SE queries, especially in DirectQuery
  • Query Store and Extended Events for DirectQuery diagnostics: Deep-dive into monitoring which SQL queries Power BI generates at the database layer and optimizing them with execution plan analysis
  • Dataflows and Computed Entities: Understand how dataflows can offload Power Query computation to Power BI's cloud engine, enabling more complex transformations without breaking DirectQuery folding
  • VertiPaq Analyzer: Use this tool to understand the memory footprint and compression ratios of your Import and aggregation tables, which helps you understand how much aggregation data you can affordably keep in memory

The path from a slow report to a fast one is a path of progressive understanding — of the engine, the source, and the specific patterns of your users. You now have the framework. Apply it methodically, and your reports will earn the trust of people who've been burned by slow dashboards before.

Learning Path: Getting Started with Power BI

Previous

Power BI Templates and Theme Files for Consistent Branding

Related Articles

Power BI🔥 Expert

Writeback Patterns in DAX: Simulating What-If Scenarios with Parameter Tables and Disconnected Slicers

30 min
Power BI⚡ Practitioner

Designing and Implementing Power BI Incremental Refresh for Large-Scale Enterprise Datasets

22 min
Power BI⚡ Practitioner

Mastering DAX Security: Dynamic Row-Level Security with USERPRINCIPALNAME and Org Hierarchies

18 min

On this page

  • Introduction
  • Prerequisites
  • Understanding the Query Pipeline: Where Time Actually Goes
  • Using Performance Analyzer: Your First Diagnostic Tool
  • Reading DAX Studio Output
  • Query Reduction Settings: Reducing the Noise
  • Report-Level: Reduce Number of Queries Sent
  • Slicer-Level Settings
  • Aggregations: The Composite Model Superpower
  • Why Aggregations Are Architecturally Important
How the Aggregation Hit/Miss Logic Works
  • Verifying Aggregation Hits
  • DirectQuery Tuning: Getting the Most From Your Source
  • Understanding Query Folding
  • Connection String Optimization
  • Writing DirectQuery-Optimized DAX
  • Source-Side Optimizations
  • Advanced: Dual Storage Mode and Its Strategic Use
  • Visual-Level Optimization Strategies
  • The Cost of High-Cardinality Axes
  • The Table and Matrix Problem
  • Minimize the Number of Visuals Per Page
  • Hands-On Exercise
  • Common Mistakes & Troubleshooting
  • Mistake 1: Aggregations configured but never hitting
  • Mistake 2: Query folding breaks after adding a "harmless" transformation step
  • Mistake 3: Import-mode aggregation table is outdated
  • Mistake 4: Too many visuals with "Disable cross-highlighting" unchecked
  • Mistake 5: Placing complex measures in a table visual in DirectQuery mode
  • Summary & Next Steps
  • Building an Aggregation Table
  • Configuring the Aggregation
  • How the Aggregation Hit/Miss Logic Works
  • Verifying Aggregation Hits
  • DirectQuery Tuning: Getting the Most From Your Source
  • Understanding Query Folding
  • Connection String Optimization
  • Writing DirectQuery-Optimized DAX
  • Source-Side Optimizations
  • Advanced: Dual Storage Mode and Its Strategic Use
  • Visual-Level Optimization Strategies
  • The Cost of High-Cardinality Axes
  • The Table and Matrix Problem
  • Minimize the Number of Visuals Per Page
  • Hands-On Exercise
  • Common Mistakes & Troubleshooting
  • Mistake 1: Aggregations configured but never hitting
  • Mistake 2: Query folding breaks after adding a "harmless" transformation step
  • Mistake 3: Import-mode aggregation table is outdated
  • Mistake 4: Too many visuals with "Disable cross-highlighting" unchecked
  • Mistake 5: Placing complex measures in a table visual in DirectQuery mode
  • Summary & Next Steps