Stop cluttering your data model with helper tables. Learn how to use ADDCOLUMNS, SUMMARIZE, and GENERATEALL to build complex, multi-level aggregations entirely within DAX measures — with full coverage of context transition, performance trade-offs, and production-ready patterns.

Here's a scenario you've probably lived through: a stakeholder asks for a report showing the top customer in each region, along with that customer's percentage contribution to the region's total revenue, ranked against the prior year's equivalent figure. You build a few calculated columns, maybe add a helper table or two, and suddenly your data model looks like a plate of spaghetti. The report works, but it's brittle, slow, and nobody — including you — wants to touch it six months later.
This is exactly the problem that DAX virtual tables solve. Instead of materializing intermediate results as actual model tables, you create temporary, in-memory table structures that exist only for the duration of a calculation. They live inside your measures, do their work, and vanish. No schema pollution, no refresh dependencies, no relationship headaches. Just clean, composable logic that handles complexity without leaving a mess behind.
By the end of this lesson, you'll understand not just how ADDCOLUMNS, SUMMARIZE, and GENERATEALL work syntactically, but why they behave the way they do at the engine level — and how to compose them into patterns that solve genuinely difficult aggregation problems. We'll work through a realistic retail analytics scenario, make deliberate mistakes together to understand failure modes, and build toward patterns that would look at home in a production BI solution.
What you'll learn:
ADDCOLUMNS and SUMMARIZE when adding expressions — and why one is almost always preferable to the otherGENERATEALL extends cross-join semantics to enable row-by-row table iteration without helper tablesThis lesson assumes you are comfortable with:
CALCULATE transitions between themSUMX, MAXX, RANKX) and what it means to iterate a tableIf filter context and context transition feel shaky, revisit those topics before continuing. Virtual tables will expose every gap in that foundation.
Before we write a single line of code, we need to establish what a virtual table actually is in the DAX engine's terms.
Every DAX function that returns a table — FILTER, ALL, VALUES, ADDCOLUMNS, SUMMARIZE, GENERATEALL, and many others — produces a table value. When you use these inside an iterator or pass them to CALCULATE, the engine materializes that table in memory for the duration of that evaluation. It is not persisted to disk. It has no relationship defined in the model. It consumes working memory proportional to its cardinality and is garbage collected when the calling expression finishes.
This matters for several reasons:
Memory and performance: Virtual tables can grow large. A SUMMARIZE over a fact table with 50 million rows might produce a summary table with 10,000 rows — small enough to iterate efficiently. But if you nest virtual table operations carelessly, you can force the engine to materialize millions of intermediate rows.
No relationships: Virtual tables do not participate in the model's relationship graph. You cannot use RELATED to hop from a virtual table row to a dimension. You must bring all the columns you need into the virtual table at construction time.
Context inside virtual table rows: When an iterator like SUMX walks a virtual table, each row establishes a new row context. If columns in the virtual table are model columns, context transition via CALCULATE will work. If columns are computed expressions, the behavior depends on how those expressions were introduced.
With that foundation in place, let's look at the tools.
ADDCOLUMNS takes an existing table expression and adds one or more computed columns to it. The syntax is:
ADDCOLUMNS(
<table>,
<column_name>, <expression>,
[<column_name>, <expression>], ...
)
Each expression is evaluated in the row context of the base table, with access to all existing columns in that table (including previously added columns, in the order they appear).
Here's the simplest useful example. Suppose you have a Sales fact table and you want a virtual table that groups sales by product category and adds a revenue calculation:
ADDCOLUMNS(
SUMMARIZE(Sales, Product[Category]),
"Total Revenue", [Total Revenue Measure]
)
Wait — we just used SUMMARIZE inside ADDCOLUMNS. That's exactly the pattern you'll use constantly. Let's look at each component before combining them.
We're working with a retail model:
Sales fact table with columns: OrderDate, CustomerKey, ProductKey, StoreKey, Quantity, UnitPrice, DiscountCustomer dimension: CustomerKey, CustomerName, Region, SegmentProduct dimension: ProductKey, ProductName, Category, BrandStore dimension: StoreKey, StoreName, City, StateCalendar dimension: Date, Year, Month, Quarter, MonthNameOur base measure:
Total Revenue = SUMX(Sales, Sales[Quantity] * Sales[UnitPrice] * (1 - Sales[Discount]))
Let's build a virtual table that shows each store's revenue, alongside the store's state:
Store Revenue Table =
ADDCOLUMNS(
ALL(Store[StoreKey], Store[StoreName], Store[State]),
"Store Revenue", CALCULATE([Total Revenue])
)
Notice CALCULATE([Total Revenue]) inside the expression. This is critical. When ADDCOLUMNS iterates the base table, each row establishes a row context. [Total Revenue] is a measure, not a column reference — you could write it without CALCULATE — but the measure will automatically leverage the filter context it inherits. The reason to be explicit about CALCULATE is when you're computing something based on the current row's column values, which requires a context transition. More on this shortly.
Let's make this more interesting. Suppose we want to add the percentage of national revenue alongside each store's revenue:
Store Revenue With Share =
ADDCOLUMNS(
ALL(Store[StoreKey], Store[StoreName], Store[State]),
"Store Revenue", CALCULATE([Total Revenue]),
"National Total", CALCULATE([Total Revenue], ALL(Store)),
"Revenue Share",
DIVIDE(
CALCULATE([Total Revenue]),
CALCULATE([Total Revenue], ALL(Store))
)
)
This is where virtual tables become powerful. You're computing multiple aggregation levels simultaneously within a single expression. No helper table. No calculated column. No stored intermediate result.
Important: Each column expression in
ADDCOLUMNSis evaluated independently. The "Store Revenue" column you defined first is not automatically available as a scalar you can reference when defining "Revenue Share." You have to re-compute it, or wrap the whole thing in anotherADDCOLUMNS. This is a common source of confusion and code duplication — we'll address it with variable composition later.
SUMMARIZE is DAX's grouping function. Its primary purpose is to produce a table of unique combinations of columns, optionally enriched with aggregations. The syntax is:
SUMMARIZE(
<table>,
<groupBy_column>, [<groupBy_column>], ...,
[<name>, <expression>], ...
)
The groupBy columns define the granularity of the result. The name/expression pairs add aggregations, similar to ADDCOLUMNS.
Here's a nuance that trips up experienced DAX authors. While SUMMARIZE technically supports adding expression columns (like an inline ADDCOLUMNS), it does so in a different evaluation context that produces subtly wrong results in many situations.
When SUMMARIZE evaluates an expression column, it does so in a filter context derived from the group-by values, not a clean row context. This means:
-- Dangerous pattern
SUMMARIZE(
Sales,
Product[Category],
"Total Revenue", [Total Revenue Measure]
)
This appears to work, and for simple measures it often does. But the expression is evaluated in a context where the filter has been applied based on the SUMMARIZE grouping, which can produce unexpected results when your measure uses ALL, ALLEXCEPT, or other filter-manipulating functions internally. The DAX engine documentation from Microsoft explicitly warns against this pattern, and the community consensus (led by analysis from Alberto Ferrari and Marco Russo at SQLBI) is clear: use ADDCOLUMNS over SUMMARIZE for adding expression columns.
The correct pattern is:
ADDCOLUMNS(
SUMMARIZE(Sales, Product[Category]),
"Total Revenue", [Total Revenue Measure]
)
SUMMARIZE handles the grouping. ADDCOLUMNS handles the expressions. Each does what it does best.
SUMMARIZE genuinely excels at producing unique combinations of related columns across table relationships. This is something VALUES or ALL alone can't do across multiple tables.
Region Category Summary =
SUMMARIZE(
Sales,
Customer[Region],
Product[Category]
)
This gives you all Region-Category combinations that actually appear in Sales. If your data has 5 regions and 8 categories but only 30 of the 40 possible combinations have actual sales, you get 30 rows — not 40. This is the "filter by what exists" behavior, and it's usually what you want for aggregation scenarios.
Tip: If you want all possible combinations regardless of whether sales exist, use
CROSSJOIN(ALL(Customer[Region]), ALL(Product[Category]))instead. This distinction is important when building baseline tables for calculations that need to show zeros.
SUMMARIZE supports subtotal rows through ROLLUP and ROLLUPADDISSUBTOTAL:
Revenue With Subtotals =
SUMMARIZE(
Sales,
ROLLUP(Customer[Region], Product[Category]),
"Total Revenue", [Total Revenue Measure]
)
Warning:
ROLLUPinsideSUMMARIZEis one of the few cases where adding expression columns directly inSUMMARIZEis necessary. You can't wrap ROLLUP-based SUMMARIZE in ADDCOLUMNS the same way. In practice, subtotal rows in virtual tables are rarely useful — if you need subtotals in a visual, let the visual engine handle it.
This is where many experienced DAX authors still make mistakes, so let's spend time here.
When ADDCOLUMNS iterates its base table, each row creates a row context. The columns of the base table are accessible via that row context. But measures and expressions that call CALCULATE need a filter context, not a row context.
Context transition is the mechanism by which row context converts to filter context. When you call CALCULATE — explicitly or implicitly by calling a measure (measures always call CALCULATE internally) — the current row context is "promoted" to filter context. Each column in the current row is added as a filter for that column's corresponding model table.
This is why this works:
ADDCOLUMNS(
SUMMARIZE(Sales, Store[StoreKey], Store[StoreName]),
"Revenue", [Total Revenue]
)
When ADDCOLUMNS evaluates [Total Revenue] for the row where Store[StoreKey] = 5 and Store[StoreName] = "Downtown Flagship", the measure invokes context transition, which adds Store[StoreKey] = 5 as a filter. The Sales table is then filtered through the relationship to return only sales from that store.
But context transition only works for model columns — columns that exist in a physical model table and have a relationship. If your base table contains computed columns (expressions you defined in an earlier ADDCOLUMNS layer), context transition will not filter on those columns, because they have no corresponding model table.
This trips people up when they do something like:
-- This won't work as expected
ADDCOLUMNS(
ADDCOLUMNS(
SUMMARIZE(Sales, Customer[Region]),
"Region Bucket", IF([Total Revenue] > 1000000, "Large", "Small")
),
"Bucket Revenue", CALCULATE([Total Revenue]) -- This does NOT filter by Region Bucket
)
The inner ADDCOLUMNS adds "Region Bucket" as a computed column. The outer ADDCOLUMNS iterates this enriched table, but context transition only picks up Customer[Region] (a model column) — it has no way to create a filter for "Region Bucket" because that column doesn't exist in the model. "Bucket Revenue" will equal overall regional revenue, ignoring the bucket logic entirely.
The solution here is to push the bucket logic into the CALCULATE filter directly:
ADDCOLUMNS(
ADDCOLUMNS(
SUMMARIZE(Sales, Customer[Region]),
"Region Bucket", IF([Total Revenue] > 1000000, "Large", "Small")
),
"Bucket Revenue",
CALCULATE(
[Total Revenue],
FILTER(
SUMMARIZE(Sales, Customer[Region]),
[Total Revenue] > 1000000
)
)
)
Before we tackle GENERATEALL, let's talk about variable composition, because it transforms complex virtual table logic from unreadable to elegant.
Variables in DAX (VAR / RETURN) capture a table value at a specific point in evaluation. This lets you build virtual tables in stages without nesting everything into one expression:
Regional Performance =
VAR RegionBase =
SUMMARIZE(Sales, Customer[Region])
VAR RegionWithRevenue =
ADDCOLUMNS(
RegionBase,
"Region Revenue", [Total Revenue]
)
VAR TotalRevenue =
CALCULATE([Total Revenue], ALL(Customer[Region]))
RETURN
ADDCOLUMNS(
RegionWithRevenue,
"Revenue Share", DIVIDE([Total Revenue], TotalRevenue)
)
Variables make each step readable and debuggable. You can evaluate RegionBase and RegionWithRevenue independently during development. And critically, TotalRevenue is computed once and reused, rather than recomputed inside each row of ADDCOLUMNS.
Performance Note: When a
VARcontains a table expression, the engine may or may not materialize it immediately depending on the execution plan. In practice, variables containing virtual tables used multiple times in theRETURNclause may be computed once and cached. But do not rely on this as a guarantee — if your variable is used in a context where filter pushdown changes its effective result, the engine may re-evaluate it.
GENERATEALL (and its strict sibling GENERATE) brings cross-join semantics with row-by-row expression evaluation. Here's the signature:
GENERATEALL(<table1>, <table2_expression>)
For each row in <table1>, it evaluates <table2_expression> — which can reference columns from the current row of <table1> — and appends the results. GENERATE drops rows from table1 where the table2 expression returns an empty table. GENERATEALL keeps all rows from table1, using blanks for table2 columns when no match exists. Think of it as INNER JOIN vs LEFT JOIN semantics.
Suppose you want to compute each store's revenue for each of the last 12 months, even for months where the store had no sales. Standard SUMMARIZE gives you only the combinations that exist. GENERATEALL lets you generate the full grid:
Store Month Grid =
GENERATEALL(
ALL(Store[StoreKey], Store[StoreName]),
ADDCOLUMNS(
VALUES(Calendar[Month]),
"Store Monthly Revenue",
CALCULATE(
[Total Revenue],
ALLEXCEPT(Calendar, Calendar[Month])
)
)
)
For each store, the inner expression produces all months with the store's revenue for that month (via context transition on Store[StoreKey] from the outer row). GENERATEALL combines these into a flat table — one row per store-month combination.
Let's build something a production analyst would actually need: a virtual table that shows, for each customer segment, the top 5 customers by revenue, along with each customer's percentage contribution to their segment total.
Top Customers Per Segment =
VAR SegmentCustomerRevenue =
ADDCOLUMNS(
SUMMARIZE(Sales, Customer[Segment], Customer[CustomerKey], Customer[CustomerName]),
"Customer Revenue", [Total Revenue]
)
VAR SegmentTotals =
ADDCOLUMNS(
SUMMARIZE(Sales, Customer[Segment]),
"Segment Total", [Total Revenue]
)
RETURN
GENERATEALL(
SegmentTotals,
VAR CurrentSegment = Customer[Segment]
VAR CurrentSegmentTotal = [Segment Total]
VAR CustomersInSegment =
FILTER(
SegmentCustomerRevenue,
Customer[Segment] = CurrentSegment
)
VAR RankedCustomers =
ADDCOLUMNS(
CustomersInSegment,
"Revenue Rank",
RANKX(
CustomersInSegment,
[Customer Revenue],
,
DESC,
DENSE
),
"Segment Contribution",
DIVIDE([Customer Revenue], CurrentSegmentTotal)
)
RETURN
FILTER(RankedCustomers, [Revenue Rank] <= 5)
)
Let's walk through what's happening:
SegmentCustomerRevenue builds the foundation: every segment-customer combination with its total revenue.SegmentTotals computes total revenue per segment.GENERATEALL iterates each segment (from SegmentTotals). For each row in SegmentTotals, the inner expression runs.CurrentSegment captures the current segment value, and CurrentSegmentTotal captures its revenue.CustomersInSegment filters the pre-computed SegmentCustomerRevenue to only rows for the current segment.RankedCustomers enriches those rows with a rank and a contribution percentage.RETURN filters to the top 5.The output is a flat table: Segment, CustomerKey, CustomerName, Customer Revenue, Revenue Rank, Segment Contribution — with exactly 5 rows per segment, assuming each segment has at least 5 customers.
Tip: Variables inside the
GENERATEALLtable2 expression (likeCurrentSegmentin the example above) capture the value of the column in the current row of table1. This is the mechanism that makes row-by-row parameterization work. It's cleaner and safer than referencing the column directly inside nested expressions, where evaluation order can be surprising.
Let's bring ADDCOLUMNS, SUMMARIZE, and GENERATEALL together in a realistic, production-grade calculation: a measure that returns the year-over-year revenue change for each product category, considering only categories where both years have sales.
We'll use this as a measure that drives a table visual:
YoY Category Revenue Analysis =
VAR CurrentYear = SELECTEDVALUE(Calendar[Year])
VAR PriorYear = CurrentYear - 1
VAR CurrentYearRevenue =
ADDCOLUMNS(
SUMMARIZE(Sales, Product[Category]),
"CY Revenue",
CALCULATE(
[Total Revenue],
Calendar[Year] = CurrentYear
)
)
VAR PriorYearRevenue =
ADDCOLUMNS(
SUMMARIZE(Sales, Product[Category]),
"PY Revenue",
CALCULATE(
[Total Revenue],
Calendar[Year] = PriorYear
)
)
VAR CombinedTable =
GENERATEALL(
CurrentYearRevenue,
VAR Cat = Product[Category]
VAR CY = [CY Revenue]
VAR PY =
MAXX(
FILTER(PriorYearRevenue, Product[Category] = Cat),
[PY Revenue]
)
RETURN
ROW(
"Category", Cat,
"CY Revenue", CY,
"PY Revenue", PY,
"YoY Change", CY - PY,
"YoY Pct", DIVIDE(CY - PY, PY)
)
)
RETURN
SUMX(
FILTER(CombinedTable, NOT(ISBLANK([PY Revenue]))),
[YoY Change]
)
Notice that the final RETURN uses SUMX over the virtual table — the virtual table is the input to the aggregation, not the output. This is the typical pattern: you build a virtual table with the granularity and computed columns you need, then aggregate it.
You'd typically use a virtual table like CombinedTable in a context where you return it to a visual directly (via a helper measure that returns a scalar for each row). But it illustrates how the three functions nest together.
Warning: The
MAXX(...FILTER(...))pattern used to look up a value inPriorYearRevenueis a common pattern for joining two virtual tables. There is no native JOIN function for virtual tables in DAX. You use filter-and-aggregate functions to simulate lookups. For small virtual tables this is fine; for large ones, the O(n²) behavior will hurt you.
Virtual tables are not free. Here's how to reason about their cost.
Every virtual table must be materialized in memory before it can be iterated. A SUMMARIZE over a 50-million-row fact table with 3 grouping columns might produce 50,000 rows — cheap. But an ADDCOLUMNS that calls a complex measure for each of those 50,000 rows will execute the measure engine 50,000 times. Each measure call may itself trigger storage engine queries.
The DAX engine uses two processing engines: the Storage Engine (SE), which retrieves data from the compressed column store, and the Formula Engine (FE), which evaluates DAX expressions. The SE is massively parallelized and very fast. The FE is single-threaded and slower.
When you use ADDCOLUMNS with a complex measure, you're pushing work into the FE for each row of the virtual table. For large virtual tables, this is the primary source of slowness.
Mitigation strategies:
CALCULATE with explicit filter arguments rather than complex FILTER expressions, so the SE can handle more of the work.FILTER(VirtualTable, FILTER(AnotherVirtualTable, ...)) — this is guaranteed to be slow.When the engine can express your virtual table as a VertiPaq query (a datacache query to the SE), it will do so. When it cannot — because your expression references another table or measure in a way that requires row-by-row evaluation in the FE — it falls back to FE iteration.
SUMMARIZE with only grouping columns (no expression columns) is typically handled entirely by the SE. Adding expressions via ADDCOLUMNS with simple aggregation measures often allows SE query generation. But FILTER with complex conditions, RANKX, or measures that themselves call CALCULATE with dynamic filters will force FE evaluation.
If you're tuning a complex virtual table measure, open DAX Studio, connect to your Power BI model, and run your measure. In the Server Timings tab, observe:
A measure that shows 95% FE time with many SE queries is a signal that your virtual table is forcing row-by-row SE lookups rather than a single columnar scan.
Work through this exercise using the retail model described in the prerequisites, or adapt it to your own star-schema model.
Build a measure (or a set of measures consumed by a table visual) that displays the following for each product brand within the current filter context:
SAMEPERIODLASTYEAR or an explicit year offset)Start with:
BrandCategoryRevenue =
ADDCOLUMNS(
SUMMARIZE(Sales, Product[Category], Product[Brand]),
"Brand Revenue", [Total Revenue]
)
Validate this by using it in a COUNTROWS measure to confirm you get the expected number of brand-category combinations.
BrandWithCategoryTotal =
ADDCOLUMNS(
BrandCategoryRevenue, -- reference the VAR from Step 1
"Category Revenue",
CALCULATE(
[Total Revenue],
ALLEXCEPT(Product, Product[Category])
)
)
Ask yourself: why does ALLEXCEPT work here inside ADDCOLUMNS? What is the filter context at the moment this expression is evaluated?
Extend the table to add a rank column using RANKX and a share column using DIVIDE. Filter to brands ranked 1 through 10 within their category.
Use GENERATEALL to iterate the category-level table and produce the brand-level detail for each category, so that your output table always contains all categories even if some brands are filtered out by external slicers.
Add a prior-year revenue column using CALCULATE([Total Revenue], SAMEPERIODLASTYEAR(Calendar[Date])) as an expression in ADDCOLUMNS. Validate that the prior year revenue is correct by cross-checking against a simpler measure in the model.
Symptom: Measures behave inconsistently, especially when they use ALL or ALLEXCEPT internally.
Fix: Always separate grouping (handled by SUMMARIZE) and expression columns (handled by wrapping in ADDCOLUMNS).
Symptom: An expression column in ADDCOLUMNS that should filter by a prior computed column returns the same value for every row.
Fix: Computed columns in virtual tables do not participate in context transition. Only model columns do. Redesign the logic so that filters reference model columns, or pass the computed value into CALCULATE explicitly as a filter argument.
Symptom: A formula error or unexpected blank when trying to reference "Column A" while defining "Column B" in the same ADDCOLUMNS call.
Fix: While DAX does evaluate ADDCOLUMNS column expressions in order and technically allows later expressions to reference earlier ones via row context, this is fragile and engine-version-dependent. Break complex chains into separate ADDCOLUMNS calls, each in its own VAR.
Symptom: Rows from table1 are mysteriously missing in the result.
Fix: GENERATE drops table1 rows where the table2 expression returns an empty table. GENERATEALL keeps all table1 rows. If you expect a full left-join behavior, use GENERATEALL.
Symptom: A measure that uses GENERATEALL takes minutes to run or crashes the model.
Fix: GENERATEALL produces a row for each combination of table1 rows and table2 rows. If table1 has 1,000 rows and the table2 expression returns 1,000 rows for each, you get 1,000,000 rows in the virtual table. Profile with DAX Studio and add filters early to reduce cardinality before the cross-join.
Symptom: Measure returns correct values but is extremely slow.
Code example of the problem:
-- Slow: virtual table is re-evaluated for every row of ADDCOLUMNS
ADDCOLUMNS(
SUMMARIZE(Sales, Product[Brand]),
"Brand Rank",
RANKX(
SUMMARIZE(Sales, Product[Brand]), -- re-evaluated each row!
[Total Revenue]
)
)
Fix: Capture the ranking table in a VAR so it's evaluated once:
VAR BrandTable = SUMMARIZE(Sales, Product[Brand])
VAR BrandWithRank =
ADDCOLUMNS(
BrandTable,
"Brand Revenue", [Total Revenue],
"Brand Rank", RANKX(BrandTable, [Total Revenue], , DESC, DENSE)
)
RETURN BrandWithRank
Symptom: Filtering works but performance is unacceptable.
Fix: Measures inside FILTER are evaluated in row context for every row of the virtual table. If the virtual table has computed revenue, filter on the precomputed column, not the measure:
-- Slow
FILTER(VirtualTable, [Total Revenue] > 100000)
-- Fast (if "Brand Revenue" is already in VirtualTable)
FILTER(VirtualTable, [Brand Revenue] > 100000)
You've covered the full conceptual and practical landscape of DAX virtual tables. Let's crystallize the key principles:
SUMMARIZE is your grouping engine. Use it to establish the granularity of your virtual table — the unique combinations of dimension values you want to work with. Don't use it to add expression columns.
ADDCOLUMNS is your enrichment engine. Layer it over SUMMARIZE output (or any table expression) to add computed columns. Each expression runs in the row context of the base table, with context transition available for model columns.
GENERATEALL is your parameterized iteration engine. Use it when you need to produce a table where the structure or content of each output group depends on values from a controlling row. It's the DAX equivalent of a correlated subquery.
Variables are the glue. Without VAR, complex virtual table logic becomes unreadable and sometimes incorrect (when the same sub-expression is re-evaluated multiple times). Always break multi-stage calculations into clearly named variables.
Context transition is the mechanism. Understanding which columns in your virtual table are model columns (eligible for context transition) and which are computed (not eligible) is the most important skill for writing correct virtual table logic.
VAR.CALCULATE with explicit filter arguments over FILTER on virtual tables wherever possible.Once you're comfortable with this foundation, the natural next topics are:
TREATAS: Applying virtual table columns as if they were relationships — the mechanism for dynamic filter propagation without model relationshipsDETAILROWS: Returning virtual tables as drillthrough detail, enabling fully dynamic detail gridsTOPNSKIP and WINDOW: Newer DAX functions that operate over ranked virtual tables with built-in windowing semantics (available in newer model compatibility levels)The real mastery of DAX lies in understanding that every calculation is a composition of table operations. Virtual tables make that composition explicit, controllable, and — when done well — both readable and fast.