Power BI Interview Questions and Answers Part 7 - Performance Optimization

Power BI Interview Q&A · Part 7 of 10

In this post we are going to cover performance optimization in Power BI: finding a slow visual, how VertiPaq compresses data, shrinking a model, writing faster DAX, aggregations and composite models, and report design. These are 7 real Power BI interview questions, and the last one is a scenario a manager loves to ask: the CEO says the sales dashboard takes 15 seconds to load.

Performance questions separate people who have supported reports in production from people who have only built them. Anyone can say "the report is slow". Interviewers want to hear how you find the cause, which tool you open first, and how you prove the fix worked.

Every example below runs on a small sample data set, so you can rebuild it in Power BI Desktop and check each number yourself.

Part 7 of the Real Time Power BI Interview Questions and Answers series on TechBrothersIT.

The short answer

Say this first

Measure first: Performance Analyzer and server timings show where the time goes. Model size is driven by cardinality, DAX should filter columns, not tables, and huge DirectQuery facts get aggregation tables with Dual dimensions.

Keep that summary in mind. Every question below is one piece of it.

Let's create some sample data

We use a tiny shop: 5 products, 4 customers in two regions, a date table for 2025 and 2026, and 12 sales in January to March of each year. The script below is written for PostgreSQL (the date table uses generate_series), and it works on any database with small changes. Load the four tables into Power BI Desktop and relate fact_sales to each dimension on its key.

Sample data - SQL script (PostgreSQL)
DROP TABLE IF EXISTS fact_sales;
DROP TABLE IF EXISTS dim_product;
DROP TABLE IF EXISTS dim_customer;
DROP TABLE IF EXISTS dim_date;

CREATE TABLE dim_product (product_key INT PRIMARY KEY, product VARCHAR(30), category VARCHAR(20));
INSERT INTO dim_product VALUES
  (1, 'Laptop Stand', 'Accessories'), (2, 'USB-C Cable', 'Accessories'),
  (3, 'Monitor', 'Electronics'),      (4, 'Keyboard', 'Electronics'),
  (5, 'Desk Lamp', 'Home');

CREATE TABLE dim_customer (customer_key INT PRIMARY KEY, customer VARCHAR(30), city VARCHAR(20), region VARCHAR(10), email VARCHAR(40));
INSERT INTO dim_customer VALUES
  (1, 'Sara Khan', 'Toronto', 'East', 'sara@tbshop.com'),
  (2, 'Omar Ali',  'Dallas',  'West', 'omar@tbshop.com'),
  (3, 'Lina Park', 'Seattle', 'West', 'lina@tbshop.com'),
  (4, 'Mei Chen',  'Boston',  'East', 'mei@tbshop.com');

-- one row per day, 2025-01-01 .. 2026-12-31 (calendar year = fiscal year here)
CREATE TABLE dim_date (date DATE PRIMARY KEY, year INT, quarter VARCHAR(2), month_no INT, month VARCHAR(3));
INSERT INTO dim_date
SELECT d, EXTRACT(YEAR FROM d), 'Q' || EXTRACT(QUARTER FROM d), EXTRACT(MONTH FROM d), TO_CHAR(d, 'Mon')
FROM generate_series(DATE '2025-01-01', DATE '2026-12-31', INTERVAL '1 day') AS g(d);

CREATE TABLE fact_sales (
  sale_id      INT PRIMARY KEY,
  order_date   DATE REFERENCES dim_date(date),
  product_key  INT REFERENCES dim_product(product_key),
  customer_key INT REFERENCES dim_customer(customer_key),
  qty          INT,
  unit_price   DECIMAL(10,2)
);
INSERT INTO fact_sales VALUES
  ( 1, '2025-01-10', 1, 1, 2,  40.00), ( 2, '2025-01-22', 3, 2, 1, 250.00),
  ( 3, '2025-02-05', 2, 3, 5,  10.00), ( 4, '2025-02-18', 5, 4, 1,  60.00),
  ( 5, '2025-03-03', 4, 1, 2,  50.00), ( 6, '2025-03-27', 3, 3, 1, 250.00),
  ( 7, '2026-01-08', 1, 2, 3,  40.00), ( 8, '2026-01-25', 3, 1, 1, 250.00),
  ( 9, '2026-02-11', 2, 4, 4,  10.00), (10, '2026-02-20', 4, 3, 2,  50.00),
  (11, '2026-03-09', 5, 2, 2,  60.00), (12, '2026-03-30', 3, 4, 2, 250.00);

Here are the 12 sales. Twelve rows will never be slow, of course, but they are enough to show how compression and DAX patterns work.

fact_sales12 rows
sale_idorder_dateproduct_keycustomer_keyqtyunit_price
12025-01-1011240.00
22025-01-22321250.00
32025-02-0523510.00
42025-02-1854160.00
52025-03-0341250.00
62025-03-27331250.00
72026-01-0812340.00
82026-01-25311250.00
92026-02-1124410.00
102026-02-2043250.00
112026-03-0952260.00
122026-03-30342250.00

And the customers, with the region column we use for the compression example.

dim_customer4 rows
customer_keycustomercityregionemail
1Sara KhanTorontoEastsara@tbshop.com
2Omar AliDallasWestomar@tbshop.com
3Lina ParkSeattleWestlina@tbshop.com
4Mei ChenBostonEastmei@tbshop.com

Question 1: How do you find out why a report page is slow?

Measure first, don't guess. Start with Performance Analyzer, on the Optimize ribbon in Power BI Desktop. Start recording, refresh the visuals, and every visual gets its own timing.

It splits the time of each visual into categories:

CategoryWhat it means
DAX querythe engine answers the query
Direct querywaiting on the DirectQuery source
Visual displaydrawing the visual
Otherpreparing, waiting on other visuals

Then copy the query of the slowest visual and run it in DAX query view, or in DAX Studio with server timings switched on.

Server timings split the time between two engines:

  • The storage engine scans the compressed data, and it is multi-threaded.
  • The formula engine runs the DAX logic, and it is single-threaded.

If most of the time is in the formula engine, rewrite the DAX. If it is in the storage engine, look at the model and the data size.

Tip

In the interview, name the tools in order: Performance Analyzer to find the visual, then DAX Studio with server timings to find the engine.

Question 2: How does the VertiPaq engine compress data, and what makes an import model big?

VertiPaq is a columnar engine: every column is stored on its own. For text columns it uses dictionary encoding. Each distinct value goes into a dictionary once, and each row just stores a small number that points to its dictionary entry.

Let's see that on the region of each sale.

Result on the sample data2 rows
valuedict_idsales_rows
East16
West26
Check this result yourself with SQL

Twelve rows, but only two distinct values: the dictionary has two entries and each row stores a small ID.

SQL
SELECT c.region AS value,
       DENSE_RANK() OVER (ORDER BY c.region) AS dict_id,
       COUNT(*) AS sales_rows
FROM fact_sales f JOIN dim_customer c ON c.customer_key = f.customer_key
GROUP BY c.region
ORDER BY c.region;

There are 12 sales rows, but only two dictionary entries: East with id 1 and West with id 2, each used by 6 rows. Twelve rows store a tiny number, and the text is stored only twice. That compresses extremely well.

So model size is driven by cardinality, the number of distinct values in a column, more than by row count. Here are the distinct values per column of our sales.

Result on the sample data6 rows
column_namedistinct_values
sale_id12
order_date12
unit_price5
product_key5
customer_key4
region2
Check this result yourself with SQL

sale_id has one distinct value per row, so it barely compresses; region has two.

SQL
SELECT column_name, distinct_values FROM (
  SELECT 'sale_id' AS column_name, COUNT(DISTINCT f.sale_id) AS distinct_values, 1 AS srt FROM fact_sales f
  UNION ALL SELECT 'order_date', COUNT(DISTINCT f.order_date), 2 FROM fact_sales f
  UNION ALL SELECT 'unit_price', COUNT(DISTINCT f.unit_price), 3 FROM fact_sales f
  UNION ALL SELECT 'product_key', COUNT(DISTINCT f.product_key), 4 FROM fact_sales f
  UNION ALL SELECT 'customer_key', COUNT(DISTINCT f.customer_key), 5 FROM fact_sales f
  UNION ALL SELECT 'region', COUNT(DISTINCT c.region), 6
  FROM fact_sales f JOIN dim_customer c ON c.customer_key = f.customer_key) t
ORDER BY srt;

sale_id has 12 distinct values in 12 rows, one per row, so it barely compresses. Region has only 2. On a real model, the big columns are the same kind: IDs, datetime columns with seconds, and free text.

Other things that make a model big:

  • Calculated columns - stored in memory.
  • Unused columns - every column costs memory.
  • Auto date/time - hidden date tables.

To see exactly where the memory goes, run VertiPaq Analyzer in DAX Studio and sort the columns by size.

Question 3: How do you reduce the size of a semantic model?

Remove what nobody uses, then reduce cardinality. Here is the checklist a senior developer would give.

FixWhy it helps
Remove unused columns and rowsevery column costs memory, even hidden
Split datetime into date and timefar fewer distinct values
Reduce precisionround decimals, drop seconds
Measures, not calculated columnscomputed at query time, not stored
Right data typesnumeric keys compress better than text
Disable Auto date/timeno hidden date table per date column
Summarize old historykeep detail only where it is needed

A few of these deserve a sentence each in the interview:

  • Unused columns cost memory even when they are hidden, so drop them, and filter out rows you don't need, like very old history.
  • Split a datetime into a date and a time. A date column has at most 366 values a year, so it compresses far better than a timestamp with seconds.
  • Numeric keys compress better than text keys.
Tip

Create derived columns in Power Query or at the source, not as DAX calculated columns, because they compress better.

Question 4: How do you write faster DAX?

Filter columns, not tables, use variables, and use DIVIDE instead of IFERROR.

Here is a typical slow measure. It calculates the share of sales that come from multi-unit sales lines (quantity 2 or more). It uses FILTER over the whole Sales table inside CALCULATE, and wraps everything in IFERROR.

DAX - Slow.dax
Multi Unit Share =
IFERROR (
    CALCULATE (
        [Total Sales],
        FILTER ( Sales, Sales[Qty] >= 2 )
    ) / [Total Sales],
    BLANK ()
)

Here is the faster version. KEEPFILTERS with a column predicate keeps the same meaning, and the engine can apply it as a simple column filter. A variable holds the result, and DIVIDE handles division by zero, so we don't need IFERROR.

DAX - Measures.dax
Multi Unit Share =
VAR MultiUnit =
    CALCULATE (
        [Total Sales],
        KEEPFILTERS ( Sales[Qty] >= 2 )
    )
RETURN
    DIVIDE ( MultiUnit, [Total Sales] )

Both versions return the same numbers.

Result on the sample data3 rows
regionmulti_unit_salestotal_salesshare_pct
East720.001030.0069.90
West390.00890.0043.82
Total1110.001920.0057.81
Check this result yourself with SQL

The slow and the fast measure return the same numbers: 720 East, 390 West, 1110 total (57.81 percent).

SQL
SELECT region, multi_unit_sales, total_sales,
       CAST(100.0 * multi_unit_sales / total_sales AS DECIMAL(5,2)) AS share_pct
FROM (
  SELECT c.region, 0 AS srt,
         SUM(CASE WHEN f.qty >= 2 THEN f.qty * f.unit_price END) AS multi_unit_sales,
         SUM(f.qty * f.unit_price) AS total_sales
  FROM fact_sales f JOIN dim_customer c ON c.customer_key = f.customer_key
  GROUP BY c.region
  UNION ALL
  SELECT 'Total', 1,
         SUM(CASE WHEN f.qty >= 2 THEN f.qty * f.unit_price END), SUM(f.qty * f.unit_price)
  FROM fact_sales f) t
ORDER BY srt, region;

Multi-unit sales are 720.00 in East and 390.00 in West, 1110.00 in total. That is 57.81 percent of all sales of 1920.00. The point is not the numbers, it is that the rewrite changes speed, not meaning.

The rules to remember:

  • Variables - evaluated once.
  • Filter columns - not FILTER over a whole table.
  • DIVIDE - instead of IFERROR, and avoid IFERROR inside iterators.
  • No nested iterators over big tables.

And always measure the change in DAX Studio. Don't guess.

Question 5: What are aggregations and composite models, and when do you use them?

When the fact table is too big to import, you keep the detail in DirectQuery and add an aggregation table: a small Import table, summarized, for example by month and product.

Here is how a query flows:

  • A report visual sends a query.
  • If the aggregation table can answer it, the query is served from memory.
  • If the user drills down to detail, the query goes to the DirectQuery source.

Set the shared dimensions, like Date and Product, to Dual storage mode. A Dual table acts as Import or DirectQuery depending on the query, so it works with both sides, and you avoid limited relationships.

Use this for very large facts where most visuals show summaries. A model that mixes Import and DirectQuery tables like this is a composite model.

Pitfall

Row-level security must filter both the aggregation table and the detail table. Automatic aggregations also exist, but they need Premium, PPU or Fabric capacity.

Question 6: How does report design affect performance, and what is the Best Practice Analyzer?

Every visual sends its own query. A page with thirty visuals sends at least thirty queries, so a fast model can still give a slow page.

What to do about it:

  • Fewer visuals - keep each page focused, and combine cards into one visual where you can.
  • Query reduction - slicers and cross-highlighting fire new queries on every click. The Query reduction preset on the Optimize ribbon turns off cross-highlighting and adds Apply buttons. It is the quick win for DirectQuery reports.
  • No huge table visuals on the landing page - avoid big tables with high-cardinality columns there.
  • Drillthrough and bookmarks - show summaries first, detail on demand.

Then run a Best Practice Analyzer. Tabular Editor has one, and in Fabric you can open a Best Practice Analyzer notebook from the semantic model. It flags things like:

  • Floating point data types
  • Bidirectional relationships
  • IFERROR in measures
  • Auto date/time turned on

Question 7: Scenario - the CEO says the sales dashboard takes 15 seconds to load. Walk me through what you do.

This is the scenario managers love. They don't want a list of tips. They want a method.

Reproduce it first. Same page, same filters, and the same security role as the CEO. A report can be fast for you and slow for the CEO because of filters or row-level security. Then run Performance Analyzer and find the worst visual.

Here is an example of what you might see. These timings are made up for illustration, they don't come from the sample data.

VisualDAX queryDisplay
Sales matrix9.0 s0.3 s
Map0.4 s0.6 s
KPI cards0.2 s0.1 s

In this example, the sales matrix takes 9.0 seconds, and almost all of it is DAX query, only 0.3 seconds of display. So it is the query, not the rendering. Copy it into DAX Studio and check server timings.

Then walk through the rest in order:

  • Check the storage mode. If it is DirectQuery, the time may be at the source, so look at the source query, indexes and aggregations.
  • Check the model size. If it is Import, run VertiPaq Analyzer.
  • Fix the biggest problem first, then measure again with the same test, and write down the before and after.
  • Set expectations. Tell the CEO what you found, what you changed, and what load time is realistic.
Tip

Measure, fix, measure again. Report the before and after, not a guess. Managers want a method and a number.

Key points to remember

  • Measure first: Performance Analyzer and server timings
  • Model size is driven by cardinality
  • Cut columns, split datetime, disable Auto date/time
  • DAX: variables, column filters, DIVIDE
  • Aggregations and Dual for huge DirectQuery facts

The full SQL script

If you want to rebuild everything at once, here is the whole script: the sample data and the SQL check behind every result above.

Show the full SQL script (sample data and every check)
Full script - copy and run top to bottom
-- ========================================================================
-- Power BI Interview Q&A #7 - Performance optimization
-- TechBrothersIT. Sample data is invented for teaching.
-- Standard SQL, tested on PostgreSQL 16. Runs top to bottom in one go.
-- Run it in an empty database or schema: other videos reuse table names
-- like dim_customer, and their leftover tables can block the DROPs below.
-- PostgreSQL example:  CREATE SCHEMA pbi_q07;  SET search_path TO pbi_q07;
-- SQL Server: write DATE '2026-06-01' as CAST('2026-06-01' AS DATE).
-- ========================================================================

-- Step 0: create and load the sample data
DROP TABLE IF EXISTS fact_sales;
DROP TABLE IF EXISTS dim_product;
DROP TABLE IF EXISTS dim_customer;
DROP TABLE IF EXISTS dim_date;

CREATE TABLE dim_product (product_key INT PRIMARY KEY, product VARCHAR(30), category VARCHAR(20));
INSERT INTO dim_product VALUES
  (1, 'Laptop Stand', 'Accessories'), (2, 'USB-C Cable', 'Accessories'),
  (3, 'Monitor', 'Electronics'),      (4, 'Keyboard', 'Electronics'),
  (5, 'Desk Lamp', 'Home');

CREATE TABLE dim_customer (customer_key INT PRIMARY KEY, customer VARCHAR(30), city VARCHAR(20), region VARCHAR(10), email VARCHAR(40));
INSERT INTO dim_customer VALUES
  (1, 'Sara Khan', 'Toronto', 'East', 'sara@tbshop.com'),
  (2, 'Omar Ali',  'Dallas',  'West', 'omar@tbshop.com'),
  (3, 'Lina Park', 'Seattle', 'West', 'lina@tbshop.com'),
  (4, 'Mei Chen',  'Boston',  'East', 'mei@tbshop.com');

-- one row per day, 2025-01-01 .. 2026-12-31 (calendar year = fiscal year here)
CREATE TABLE dim_date (date DATE PRIMARY KEY, year INT, quarter VARCHAR(2), month_no INT, month VARCHAR(3));
INSERT INTO dim_date
SELECT d, EXTRACT(YEAR FROM d), 'Q' || EXTRACT(QUARTER FROM d), EXTRACT(MONTH FROM d), TO_CHAR(d, 'Mon')
FROM generate_series(DATE '2025-01-01', DATE '2026-12-31', INTERVAL '1 day') AS g(d);

CREATE TABLE fact_sales (
  sale_id      INT PRIMARY KEY,
  order_date   DATE REFERENCES dim_date(date),
  product_key  INT REFERENCES dim_product(product_key),
  customer_key INT REFERENCES dim_customer(customer_key),
  qty          INT,
  unit_price   DECIMAL(10,2)
);
INSERT INTO fact_sales VALUES
  ( 1, '2025-01-10', 1, 1, 2,  40.00), ( 2, '2025-01-22', 3, 2, 1, 250.00),
  ( 3, '2025-02-05', 2, 3, 5,  10.00), ( 4, '2025-02-18', 5, 4, 1,  60.00),
  ( 5, '2025-03-03', 4, 1, 2,  50.00), ( 6, '2025-03-27', 3, 3, 1, 250.00),
  ( 7, '2026-01-08', 1, 2, 3,  40.00), ( 8, '2026-01-25', 3, 1, 1, 250.00),
  ( 9, '2026-02-11', 2, 4, 4,  10.00), (10, '2026-02-20', 4, 3, 2,  50.00),
  (11, '2026-03-09', 5, 2, 2,  60.00), (12, '2026-03-30', 3, 4, 2, 250.00);


-- Step 1: Dictionary encoding: Region in the Sales rows
SELECT c.region AS value,
       DENSE_RANK() OVER (ORDER BY c.region) AS dict_id,
       COUNT(*) AS sales_rows
FROM fact_sales f JOIN dim_customer c ON c.customer_key = f.customer_key
GROUP BY c.region
ORDER BY c.region;

-- Step 2: Cardinality: distinct values per column of Sales
SELECT column_name, distinct_values FROM (
  SELECT 'sale_id' AS column_name, COUNT(DISTINCT f.sale_id) AS distinct_values, 1 AS srt FROM fact_sales f
  UNION ALL SELECT 'order_date', COUNT(DISTINCT f.order_date), 2 FROM fact_sales f
  UNION ALL SELECT 'unit_price', COUNT(DISTINCT f.unit_price), 3 FROM fact_sales f
  UNION ALL SELECT 'product_key', COUNT(DISTINCT f.product_key), 4 FROM fact_sales f
  UNION ALL SELECT 'customer_key', COUNT(DISTINCT f.customer_key), 5 FROM fact_sales f
  UNION ALL SELECT 'region', COUNT(DISTINCT c.region), 6
  FROM fact_sales f JOIN dim_customer c ON c.customer_key = f.customer_key) t
ORDER BY srt;

-- Step 3: Multi-unit sales (qty >= 2) and share of sales, by region
SELECT region, multi_unit_sales, total_sales,
       CAST(100.0 * multi_unit_sales / total_sales AS DECIMAL(5,2)) AS share_pct
FROM (
  SELECT c.region, 0 AS srt,
         SUM(CASE WHEN f.qty >= 2 THEN f.qty * f.unit_price END) AS multi_unit_sales,
         SUM(f.qty * f.unit_price) AS total_sales
  FROM fact_sales f JOIN dim_customer c ON c.customer_key = f.customer_key
  GROUP BY c.region
  UNION ALL
  SELECT 'Total', 1,
         SUM(CASE WHEN f.qty >= 2 THEN f.qty * f.unit_price END), SUM(f.qty * f.unit_price)
  FROM fact_sales f) t
ORDER BY srt, region;
Next in this series

Part 8: Row-level Security and Governance

The sample data on this page is invented for teaching. The result tables were produced by running the same calculation in SQL on PostgreSQL 16 over that sample data (open "Check this result yourself" under any result), so you can rebuild every number in Power BI Desktop and compare.

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.