Most Power BI performance problems can't be fixed with DAX rewrites — they live in the data model's storage layer. This lesson teaches you to use VertiPaq Analyzer to diagnose column cardinality failures, relationship materialization costs, and compression anti-patterns that are silently bloating your model and killing query performance at scale.

You've built a data model that works. Measures return the right numbers, relationships are defined, and the report looks great in development. Then you publish it to a workspace with 200,000 rows of transaction data, add a handful of slicers, and watch the whole thing grind to a halt. Page render times balloon to eight seconds. Premium capacity utilization spikes. Users start complaining, and your manager starts asking uncomfortable questions.
This isn't a DAX syntax problem. It's a data model architecture problem — and the most powerful tool for diagnosing it isn't DAX Studio's query plan view, it's VertiPaq Analyzer. VertiPaq Analyzer lets you peer inside the in-memory columnar storage engine that powers Power BI (and Analysis Services) and see exactly what's eating your memory, which columns are impossible to compress, which relationships are creating expensive materialization at query time, and where the gap between your logical model and the physical storage reality is causing performance to collapse.
By the end of this lesson, you'll be able to read a VertiPaq Analyzer report with genuine fluency — not just glancing at the summary numbers, but understanding the relationship between cardinality, encoding, dictionary size, and query-time cost. You'll be able to make concrete structural decisions that reduce model size by 40-80% in realistic scenarios and eliminate the bottlenecks that no amount of DAX rewriting will fix.
What you'll learn:
This lesson assumes you're comfortable with Power BI Desktop, have built multi-table data models with relationships, and understand basic DAX measure authoring. You should be familiar with the difference between calculated columns and measures — if that's fuzzy, revisit DAX Fundamentals: When to Use Calculated Columns vs Measures in Power BI before continuing. You'll also want DAX Studio installed (free from daxstudio.org) since VertiPaq Analyzer is accessed through it.
Before touching VertiPaq Analyzer, you need a mental model of how VertiPaq stores data. This isn't optional background — it directly determines how you interpret every number in the tool.
VertiPaq is a columnar, in-memory store. Unlike row-based databases that keep each record together, VertiPaq stores each column independently. This means when Power BI evaluates a DAX measure that filters on [ProductCategory] and sums [SalesAmount], it only needs to load two columns into the CPU cache, not the entire row. That's the fundamental speed advantage.
Each column is compressed through two encoding strategies:
Value Encoding applies to numeric columns with a narrow value range. VertiPaq stores the minimum value and an offset for each subsequent value, typically achieving very high compression ratios. An integer column containing values like 1000, 1001, 1002, 1003 compresses extremely well this way.
Hash Encoding (also called Dictionary Encoding) applies to everything else — text columns and numeric columns whose range is too wide for value encoding. VertiPaq builds a dictionary that maps each unique value to an integer ID, then stores a column of those IDs. The dictionary is stored separately from the column data.
This is why cardinality — the number of distinct values in a column — is so critical. A column with 10 distinct values needs a dictionary of 10 entries and can store its IDs in very few bits. A column with 10 million distinct values needs a dictionary of 10 million entries, and each row must store an ID large enough to reference any of those 10 million values. The compression catastrophically degrades.
Key insight: Cardinality doesn't just affect dictionary size — it determines the bit density of the ID column itself. A column with 256 distinct values can store each ID in 8 bits. A column with 65,537 distinct values requires 17 bits per row. At 100 million rows, that's 212 MB vs. 1.7 GB for what might be logically similar data. VertiPaq chooses the minimum bit depth to represent the cardinality, so reducing cardinality has a compounding effect on memory.
VertiPaq also applies Run-Length Encoding (RLE) on top of the dictionary-encoded ID column. When consecutive rows share the same value — which happens frequently after VertiPaq's internal sorting — it stores [value, count] pairs instead of repeating the value. This is why column sort order matters even though you never explicitly define it: VertiPaq reorders rows internally to maximize RLE opportunities across multiple columns simultaneously, using a proprietary algorithm. You can influence this through your model structure choices, but you can't directly control the sort order.
The practical implication: two tables with the same row count can have radically different memory footprints depending on how their columns are distributed across cardinality ranges. VertiPaq Analyzer makes this visible.
VertiPaq Analyzer is built into DAX Studio. With your Power BI file open in Desktop, connect DAX Studio to it:
The result is a multi-tab interface. The key tabs are Summary, Tables, Columns, and Relationships.
On the Summary tab, you'll see total model size, the number of tables, total columns, and total rows. These headline numbers tell you roughly where you are, but the real work happens in the Columns tab.
Switch to the Columns tab. This is your primary diagnostic surface. You'll see one row per column with these fields:
Sort by Total Size descending. The top 10 columns in that list are your optimization targets. In most models, a handful of columns account for the majority of model size — and most of those columns are candidates for improvement.
Tip: Run VertiPaq Analyzer after every data refresh during optimization work, not just once at the beginning. Some changes only show their full effect after the storage engine re-encodes the column from scratch.
Let's walk through a realistic scenario. Imagine you're working on a retail analytics model with a SalesTransactions fact table containing 12 million rows. After loading VertiPaq Analyzer, your top columns by Total Size look something like this (approximate numbers):
| Column | Cardinality | Data Size | Dict Size | Total Size | Encoding |
|---|---|---|---|---|---|
| SalesTransactions[TransactionID] | 12,000,000 | 96 MB | 91 MB | 187 MB | HASH |
| SalesTransactions[TransactionTimestamp] | 4,200,000 | 67 MB | 34 MB | 101 MB | HASH |
| SalesTransactions[CustomerNote] | 890,000 | 45 MB | 78 MB | 123 MB | HASH |
| SalesTransactions[SalesAmount] | 340,000 | 12 MB | 3 MB | 15 MB | HASH |
| SalesTransactions[ProductID] | 28,000 | 4 MB | 1 MB | 5 MB | HASH |
The TransactionID column screams at you immediately. Cardinality of 12 million in a 12 million row table — it's unique per row. This column cannot compress at all. Every row is a distinct dictionary entry, and VertiPaq has to store all 12 million values in the dictionary plus a dense integer ID column. This column consumes 187 MB and provides zero analytical value in a fact table. You almost certainly don't filter on it in any measure.
The TransactionTimestamp column is the second lesson. Timestamps with millisecond precision are the arch-enemy of VertiPaq compression. Even if only 50,000 transactions happen per day, millisecond timestamps guarantee that almost no two rows share the same value. In this case, 4.2 million distinct timestamps out of 12 million rows — only trivial compression is possible.
The CustomerNote column is more interesting. 890,000 distinct values suggests free-text data — customer-entered notes, comments, or descriptions. This column is analytically useless for aggregation and will never be used in a measure. It's sitting in your fact table costing 123 MB because someone included it "just in case."
Warning: The most expensive columns in most production models are not the ones containing business data — they're surrogate keys with full cardinality, timestamp columns with sub-day precision, and free-text description fields that were imported without filtering. These have zero analytical value and maximum storage cost. Identify and remove them first.
What's your action plan for this model?
Drop TransactionID entirely from the fact table query if it's not used in any measure or relationship. If you need it for drill-through, consider whether a limited row count approach or a separate detail table would serve that need better.
Truncate TransactionTimestamp to date if you don't need sub-day time analysis, or truncate to the hour if you do. Changing a datetime from millisecond precision to date-only might reduce cardinality from 4.2 million to 1,095 (days over 3 years), achieving a compression factor that reduces this column from 101 MB to under 1 MB.
Remove CustomerNote from the fact table import. If business stakeholders insist on seeing it, create a separate lookup table with one row per transaction that's only loaded for drill-through scenarios.
The structural change to the TransactionTimestamp column is a good example of an optimization that has nothing to do with DAX. No measure rewrite achieves this. You have to go back to Power Query, change the transformation, and let VertiPaq re-encode the column.
Not every high-cardinality column is a problem. VertiPaq Analyzer gives you both Data Size and Dictionary Size, and the relationship between them tells you what kind of problem you're dealing with.
When Dictionary Size dominates Total Size, your column has many distinct values and those values are large (long text strings). The problem is the breadth of your value space. Shortening the strings or reducing cardinality are both valid fixes.
When Data Size dominates Total Size, your column has manageable distinct values but poor RLE efficiency — consecutive rows don't share values because the data is effectively random or sorted in a way that fragments runs. This might indicate a partitioning problem or that the data simply doesn't cluster well.
When both are large, you have a column that's both high cardinality and populated with large values. This is the worst case.
Consider a ProductDescription column with 28,000 distinct values. If each description averages 80 characters, the dictionary alone is 28,000 × 80 bytes = 2.2 MB, which is manageable. But if descriptions average 800 characters (not unusual for product catalog data), the dictionary becomes 22 MB and the encoding of 12 million IDs adds more on top.
The question you always need to ask: is this column used in any filter, slicer, grouping, or relationship? If the answer is no, it doesn't belong in the model. If the answer is "it's used in drill-through," consider moving it to a separate detail table that's only queried when a user explicitly requests the detail view.
Note: VertiPaq Analyzer reports sizes after compression. A column that looks "small" in VertiPaq Analyzer might actually be contributing significantly to query time through poor RLE efficiency even if its byte count looks acceptable. The Segments column helps here — a very high segment count relative to row count can indicate partitioning fragmentation that increases scan overhead.
Understanding how relationships perform at query time requires connecting VertiPaq Analyzer data with your knowledge of how filters propagate — the mechanics described in DAX Relationships 101: How Power BI Filters Flow Across Tables and Why It Matters for Your Measures.
When VertiPaq evaluates a query that crosses a relationship, it performs a join operation internally. But the way it does this depends on the relationship cardinality and the filter direction. The Relationships tab in VertiPaq Analyzer shows you each relationship with:
The Used Size number is critical and frequently misunderstood. Relationships are not free. VertiPaq maintains internal structures called VertiPaq Relationship Data (VRD) that encode the join mapping between tables. These structures grow with cardinality — specifically, with the cardinality of the many-side key column.
In the retail model, a relationship between SalesTransactions[ProductID] and Products[ProductID] might have a Used Size of 15-30 MB depending on transaction volume. But a relationship between SalesTransactions[CustomerID] and Customers[CustomerID] where there are 900,000 distinct customers in a 12M row transaction table might have a Used Size of 180 MB or more.
Missing Rows is a diagnostic signal of a different kind. If VertiPaq Analyzer shows that 15% of your fact rows have no matching dimension key, you have referential integrity issues. These aren't just a data quality concern — VertiPaq has to maintain a special "blank" row for unmatched fact rows, and filters across this relationship produce unexpected results in DAX. You'll find that measures using CALCULATE and filter context behave unpredictably when significant missing row counts exist because the blank dimension row absorbs all mismatched fact rows into a single group.
Key insight: The relationship Used Size scales with the cardinality of the join key on the many side, not the one side. A customer table with 900,000 rows joined to a transaction table with 12 million rows creates relationship data sized by those 12 million transaction rows. Reducing the transaction table's key cardinality (by aggregating or pre-summarizing where appropriate) is more impactful than reducing the customer table.
If you're using bidirectional cross-filter relationships or bridge tables — patterns discussed in DAX for Many-to-Many Relationships and Complex Data Models — VertiPaq Analyzer will reveal that these relationships carry significantly higher Used Size than their equivalent unidirectional relationships. This is because the storage engine must maintain join indexes in both directions.
The Relationships tab will show two entries for a bidirectional relationship, one for each direction. If you see that a bidirectional relationship between two large tables consumes several hundred MB in total relationship data, this is a strong signal to reconsider whether you truly need bidirectional filtering or whether you can achieve the same result with explicit DAX using CROSSFILTER() or USERELATIONSHIP() only in the measures that require it.
Let me walk through the most impactful structural patterns you can implement based on VertiPaq Analyzer findings. These aren't theoretical — each represents a type of optimization that routinely produces 40-80% model size reduction in production models.
The single most reliable optimization technique. Instead of storing text keys throughout your fact table — product names, category strings, customer names — normalize them to integer IDs and keep the text in dimension tables.
Before optimization, imagine a SalesTransactions fact table with these columns: ProductCategory (text), StoreName (text), RegionCode (text). All three appear in every row. Even with moderate cardinality (categories: 12, stores: 340, regions: 28), storing repeated text strings is inefficient because each dictionary entry is a multi-byte string and each row references that dictionary.
After optimization, these become integer foreign keys that join to small dimension tables. VertiPaq encodes integer columns with value encoding at extremely high compression ratios. A column with 12 distinct integer values compresses to near nothing. The text values live once in a 12-row dimension table.
This pattern also enables the storage engine to achieve better RLE because integer columns with low cardinality produce long runs when the data is sorted by dimension.
For datetime columns, split into separate date and time components in Power Query before loading:
// In Power Query, split a DateTime column into components
#"Added Date" = Table.AddColumn(
Source,
"TransactionDate",
each Date.From([TransactionTimestamp]),
type date
),
#"Added Hour" = Table.AddColumn(
#"Added Date",
"TransactionHour",
each Time.Hour([TransactionTimestamp]),
type number
),
#"Removed Timestamp" = Table.RemoveColumns(
#"Added Hour",
{"TransactionTimestamp"}
)
TransactionDate now has at most 365 × number-of-years distinct values. TransactionHour has at most 24 distinct values. The combined storage of these two columns is a fraction of the original timestamp column. You can join TransactionDate to your date dimension table as a proper relationship — something you couldn't reliably do with a full datetime column. This enables time intelligence calculations to use relationship-based filtering rather than expensive CALCULATE-based date comparisons.
Before removing any column from your model, you need to know whether it's actually used. DAX Studio has a feature for this: the "Model Dependencies" view shows which columns are referenced by measures, calculated columns, relationships, and row-level security rules.
Any column that appears in the VertiPaq Analyzer with significant Total Size but zero references in Model Dependencies is a candidate for removal. In production models, it's common to find 15-30% of columns fitting this description — imported "for future use" and never connected to any measure or visualization.
Warning: Always check whether a column is used in Row-Level Security rules before removing it. RLS rules appear in the semantic model but aren't always visible through DAX Studio's dependency viewer depending on the version. If your model has RLS, cross-reference against your security roles manually. The article on Dynamic Row-Level Security explains the relationship between model columns and security predicates in detail.
When a fact table has millions of rows but your reports never need row-level detail, pre-aggregating in Power Query before loading into VertiPaq is a legitimate and often dramatic optimization.
A transaction table with 50 million rows, one per line item, might aggregate to 200,000 rows when grouped by [Date, ProductID, StoreID, CustomerSegment] with summed measures. If your reports only ever show sales by those dimensions, the 50 million row table is doing nothing except consuming memory and slowing every query.
The trade-off: you lose the ability to drill to individual transaction level. The design decision is whether to accept that trade-off for your main analytical model and provide a separate detail table for the rare cases where transaction-level drill-through is needed.
Calculated columns vs measures is a fundamental design choice with major compression implications. Calculated columns are materialized — they take up space in the VertiPaq store just like any other column, including going through the encoding process.
If you have a calculated column that produces high-cardinality output — for example, computing [GrossMarginPct] as DIVIDE([Margin], [Revenue]) at the row level on a 12 million row fact table — you're materializing 12 million floating-point values into the store. Floating-point columns with near-continuous value distributions compress poorly.
VertiPaq Analyzer will show this calculated column consuming significant Data Size and Dictionary Size. Moving the calculation to a measure eliminates this entirely — a measure is computed at query time from already-compressed source columns rather than being stored pre-computed.
// Don't do this in a 12M row fact table
// Calculated Column - materializes 12M computed values
GrossMarginPct = DIVIDE(SalesTransactions[Margin], SalesTransactions[Revenue])
// Do this instead - computes only for the current filter context
Gross Margin % = DIVIDE(SUM(SalesTransactions[Margin]), SUM(SalesTransactions[Revenue]))
The calculated column version adds size proportional to the row count and cardinality of the computed values. The measure version adds zero bytes to the model and computes in microseconds for typical filter contexts. As explained in DAX Aggregation Functions Demystified, DIVIDE with SUM operates directly on the compressed column storage without needing a pre-computed column.
VertiPaq Analyzer's segment-level data is underutilized by most analysts, but it's critical for understanding performance in large models.
Each VertiPaq table is divided into segments — blocks of up to 8 million rows (the default segment size). Each segment is independently encoded and can be evaluated independently during a scan. This has two major implications:
Parallelism: VertiPaq can scan multiple segments in parallel using multiple CPU cores. A table with 80 million rows divides into roughly 10 segments of 8 million rows each, allowing up to 10-way parallelism on the scan.
Segment Elimination: VertiPaq maintains min/max statistics per segment per column. If a filter predicate on [Date] covers only a range within a single segment, VertiPaq can skip all other segments entirely. This is analogous to partition elimination in SQL and can be enormously powerful for time-based filtering.
In VertiPaq Analyzer, look at the Segments count and Rows Per Segment for your fact tables. A table with 12 million rows should show 2 segments of ~6 million rows each (or similar). If you see 50 segments with 240,000 rows each, your model has been partitioned (either through Power BI's incremental refresh or through Analysis Services partition management), and each partition creates its own segment stack.
High segment count with low row density creates overhead: the engine has more segments to initialize and manage, and within each small segment, RLE compression is less effective because there are fewer rows for runs to accumulate across. The sweet spot for general-purpose models is segments with 2-8 million rows.
Tip: If you're using incremental refresh in Power BI Premium, periodically check that older partitions are being merged as designed. Incremental refresh creates new partitions for recent data periods, and if merge policies aren't working correctly, you can accumulate dozens of tiny partitions for historical data. Each tiny partition creates a separate segment with poor compression density.
Rather than approaching model optimization reactively, build a repeatable process:
Step 1: Establish a Baseline Load VertiPaq Analyzer and export the results (there's an Excel export button in the toolbar). Record total model size, the top 10 columns by Total Size, and the top 5 relationships by Used Size. This is your before snapshot.
Step 2: Calculate Theoretical Impact
For each problematic column, estimate the post-optimization size. A TransactionTimestamp column with 4.2M distinct values that you're truncating to date level with ~1,000 distinct values represents roughly a 4,200x reduction in cardinality. The actual size reduction won't be 4,200x (there's still the ID column to store, plus other overhead), but you can estimate it will drop from 101 MB to 1-2 MB. Prioritize by estimated impact.
Step 3: Make One Change at a Time Resist the temptation to restructure everything simultaneously. Make one change in Power Query or the data model, refresh the data, reload VertiPaq Analyzer, and measure the effect. Multiple simultaneous changes make it impossible to attribute impact to individual decisions.
Step 4: Validate DAX Correctness
Every structural change you make has the potential to break measures. After each optimization, run a validation script in DAX Studio that compares key measures between your baseline model and the optimized model for a consistent set of filter contexts. If you're using DAX Query View, write a test suite using EVALUATE statements as described in DAX Query View Mastery.
Step 5: Profile Query Performance VertiPaq Analyzer tells you about storage — but storage optimization should translate to query performance improvement. After optimizing the model structure, use DAX Studio's Server Timings to measure the query execution time for your most critical report pages. You should see Storage Engine (SE) query times decrease proportionally to your memory reduction. If they don't, the bottleneck may have shifted to a Formula Engine (FE) issue, which requires a different optimization approach covered in Performance Tuning DAX: Optimize Slow Measures with DAX Studio.
// Example validation query structure - run in DAX Studio
EVALUATE
SUMMARIZECOLUMNS(
'Date'[Year],
'Products'[Category],
"Total Sales", [Total Sales],
"Gross Margin %", [Gross Margin %],
"Transaction Count", [Transaction Count]
)
ORDER BY 'Date'[Year], 'Products'[Category]
Run this same query against the pre-optimization and post-optimization model, export both, and diff them. Any discrepancy means a structural change broke something.
Work through this exercise on a model with at least 500,000 rows. If you don't have one readily available, download the AdventureWorks PBIX sample — Microsoft provides this as a public download and it contains multiple tables with interesting cardinality characteristics.
Part 1: Initial Assessment (15 minutes)
Part 2: Relationship Analysis (10 minutes)
Part 3: Implement One Optimization (20 minutes)
Choose the single highest-impact optimization from your analysis. Most likely this will be one of:
Implement the change in Power Query, refresh the model, and reload VertiPaq Analyzer.
Part 4: Measure the Impact
Compare your before and after Total Size numbers. Calculate the percentage reduction for the targeted column and for the overall model. Document whether the change produced any measure validation failures (run your validation queries).
Expected outcome: For a typical production model, removing one high-cardinality unique ID column and truncating one datetime column should reduce total model size by 15-40%.
New users often focus exclusively on Cardinality to identify problem columns, but high cardinality with small dictionary entries (short integers) may be less impactful than moderate cardinality with large dictionary entries (long strings). Always prioritize by Total Size. A column with 50,000 distinct values averaging 200-character strings will dominate a column with 5 million distinct integer values.
When you remove a column from the model, Power BI will break any implicit measures (auto-generated aggregations) based on that column. More insidiously, it will also break visualizations where that column was dragged directly to a visual. If your report has auto-generated measures, read DAX Implicit vs Explicit Measures before making any structural changes — explicit measures with clearly documented dependencies are far safer to refactor around.
VertiPaq Analyzer shows you the at-rest model size. During a data refresh, VertiPaq builds a new version of each affected table alongside the existing one before swapping. Peak memory usage during refresh can be 2-3x the at-rest model size. If your model is 4 GB at rest, plan for 8-12 GB RAM on your Premium capacity during refresh. If you're seeing failed refreshes on Premium, check whether total model + refresh overhead is exceeding your capacity's memory limit.
Dimension tables are typically small — hundreds to thousands of rows. Even a catastrophically bad dimension table with 100,000 rows and 50 columns is unlikely to consume more than a few hundred MB. The fact table with millions of rows is where optimization has exponential impact. Focus 80% of your effort on fact table column reduction before touching dimensions.
If you've configured incremental refresh in Power BI Premium and see a high segment count, that's expected behavior. The segments represent your historical and recent data partitions. What you're looking for is whether historical partitions have been merged (they should be, once they fall outside the rolling window). If you see 36 monthly partitions all with similar row counts, check that your incremental refresh policy is actually running and merging as configured.
Calculated tables — tables created using DAX expressions in the model — are fully materialized in VertiPaq storage just like imported tables. If you've used SUMMARIZECOLUMNS or ADDCOLUMNS to create intermediate calculated tables, each one appears in VertiPaq Analyzer and consumes memory proportional to its size and cardinality. This is often the hidden cost of what appears to be a "clean" model architecture. When possible, push aggregation logic to Power Query (M) at load time rather than creating calculated tables in DAX.
VertiPaq Analyzer moves you from guessing about model performance to measuring it with precision. The core mental model to carry forward: compression efficiency is determined by cardinality, and cardinality is determined by your data model design choices made before a single measure is written. High-cardinality unique identifiers, full-precision timestamps, free-text description columns, and pre-computed calculated columns with continuous value distributions are the four patterns that most reliably destroy compression.
The systematic workflow — baseline measurement, theoretical impact estimation, single-change iteration, validation, and query profiling — is what separates one-time optimization from a sustainable practice. Build this workflow into your development process, not just your incident response process.
Your next areas to deepen:
Storage Engine vs. Formula Engine: VertiPaq Analyzer addresses storage-layer costs, but query performance also depends on how much work the Formula Engine must do. Study Server Timings in DAX Studio to understand the SE/FE split for your worst-performing measures. The Performance Tuning DAX lesson addresses this directly.
Aggregations in Power BI: For very large models that can't be sufficiently reduced through column elimination, user-defined aggregation tables let VertiPaq serve most queries from a pre-aggregated summary and only fall back to the full detail table for granular requests. This is a Premium feature worth understanding for enterprise-scale deployments.
Calculation Groups for Measure Efficiency: Heavy use of time intelligence patterns — explored in Advanced Time Intelligence: Custom Calendars, Fiscal Years, and ISO Weeks — can be made more efficient through calculation groups that centralize the CALCULATE context-switching logic rather than repeating it across dozens of individual measures.
Many-to-Many Relationship Costs: If VertiPaq Analyzer reveals that your relationship data structures are consuming hundreds of MB, revisit your join design. DAX Virtual Tables in Practice shows how SUMMARIZE and ADDCOLUMNS can sometimes replace expensive physical relationships with computed virtual relationships that are evaluated only when needed.
The skills in this lesson — reading compression metrics, tracing cardinality to storage cost, and connecting relationship structure to memory consumption — are what distinguish a model builder from a model architect. Practice with every model you touch, and within a few iterations you'll start making these structural decisions automatically before the data even lands in Power BI Desktop.