Topic page · The Paradox of Abstraction

Nested Views: Three Lines of SQL, One Huge Plan

One view for the orders, one on top of it with the customers, one on top of that with the products, and finally the reporting view. Each layer is easy to understand on its own, and the query at the end fits in three lines:

Updated Nov 4, 2026 · approx. 9 min read

Topic Views · Compile · Join eliminationLab SQL Server 2017–2025
SELECT …FROM vw_BerichtWHERE …vw_Berichtvw_…vw_…vw_…vw_…vw_…vw_…vw_…vw_…vw_…one view, many tables in the plan
SELECT *FROM dbo.vw_SalesReportingWHERE CustomerID = 42;

What the optimizer receives is something else. Before it looks for a plan, SQL Server replaces every view with its definition, level by level. Three lines become one query with all the joins, subqueries, and expressions of all the layers. This deep dive shows what happens, when it goes well, and how you can tell that the optimizer is overwhelmed.

A view is not a precomputed module

An ordinary view stores only its definition, no data and no plan. It is expanded on every query, and the optimizer sees only the tables behind it. That is often a good thing: it can push filters down, reorder joins, and leave out parts the query does not need. It also means that every layer brings its work into every query built on top of it.

Only an indexed view stores results. It is a tool of its own, with its own rules and write costs, and not the topic of this page.

When the optimizer removes joins again

If the query asks for only a few columns, the optimizer can leave out joins to tables from which nothing is needed. This is called join elimination, and it requires the optimizer to know for certain that the join does not change the row count:

  • For an inner join, it needs a trusted foreign key (created or checked WITH CHECK) on columns that are NOT NULL.
  • For an outer join (LEFT JOIN), a unique key on the right side is enough.

In the lab, there was a chain of up to 16 views. Each level builds on the previous one, adds a dimension table through an inner join, and outputs its name. The query needs only the order number and the name from the first dimension:

A chain of 16 views and a query that needs almost none of it

vw_L1 to vw_L16
-- Level 1: orders and the first dimensionCREATE VIEW dbo.vw_L1 ASSELECT o.OrderId, o.D1Id, /* … */ o.D16Id, o.Amount,       d1.Name AS D1NameFROM dbo.Orders AS oJOIN dbo.D1 AS d1 ON o.D1Id = d1.Id;GO -- Level 2: the level below plus the second dimensionCREATE VIEW dbo.vw_L2 ASSELECT v.OrderId, v.D1Id, /* … */ v.Amount, v.D1Name,       d2.Name AS D2NameFROM dbo.vw_L1 AS v   -- builds on the view belowJOIN dbo.D2 AS d2 ON v.D2Id = d2.Id;GO -- … and so on up to level 16CREATE VIEW dbo.vw_L16 ASSELECT v.OrderId, /* … */ v.D15Name,       d16.Name AS D16NameFROM dbo.vw_L15 AS vJOIN dbo.D16 AS d16 ON v.D16Id = d16.Id;
What makes join elimination possible
-- Trusted: created WITH CHECK, column D1Id is NOT NULLALTER TABLE dbo.Orders WITH CHECK    ADD CONSTRAINT FK_Orders_D1    FOREIGN KEY (D1Id) REFERENCES dbo.D1 (Id);   -- exactly one D1 per order-- … likewise for D2 to D16 -- Check: is_not_trusted must be 0SELECT name, is_not_trusted, is_disabledFROM sys.foreign_keysWHERE parent_object_id = OBJECT_ID('dbo.Orders');
Needs only order number and D1Name
SELECT OrderId, D1Name   -- two columns out of 16 levelsFROM dbo.vw_L16WHERE OrderId BETWEEN 1000 AND 1100;

with foreign keys 2 tables in the plan, 1.4 ms · without 17 tables, 4.0 ms · compilation 50 and 62 ms

The same query directly on the tables
SELECT o.OrderId, d1.Name AS D1NameFROM dbo.Orders AS oJOIN dbo.D1 AS d1 ON o.D1Id = d1.IdWHERE o.OrderId BETWEEN 1000 AND 1100;

2 tables in the plan, 1.3 ms · compilation 1 ms

SELECT * on the same view
SELECT *   -- now every dimension is needed, nothing drops outFROM dbo.vw_L16WHERE OrderId BETWEEN 1000 AND 1100;

17 tables in the plan, with and without foreign keys · 6.1 ms, same as flat · compilation 63 instead of 24 ms

Code as in the lab; column lists shortened with /* … */, schema unified as dbo. Results on SQL Server 2022, median.

The same chain existed twice: once with trusted foreign keys from the orders to all dimensions, once without. At 16 levels:

VersionTables in the plan with / without foreign keysCompile time with / without foreign keys / flatRun time with / without foreign keys
SQL Server 20172 / 1738 / 49 / 1 ms1.4 / 3.5 ms
SQL Server 20192 / 1748 / 58 / 1 ms1.3 / 3.7 ms
SQL Server 20222 / 1750 / 62 / 1 ms1.4 / 4.0 ms
SQL Server 20252 / 1756 / 82 / 2 ms1.5 / 4.2 ms

“Flat” is the same query directly on the two tables it needs.

With foreign keys, none of the 16 joins remained. The plan read only the orders and the first dimension, 9 pages, like the flat query. Without foreign keys, it read all 17 tables and 3,237 pages. With 101 rows, that is only milliseconds. The difference becomes clearer when the query groups over all orders: with foreign keys, it ran as fast as the flat query, 25 to 64 ms. Without foreign keys, it took 107 to 155 ms on SQL Server 2019 to 2025, and 918 ms on 2017.

The compile time stands out. Even though all the needless joins are removed in the end, the view query compiled 20 to 50 times as long as the flat one. SQL Server first expands all levels and removes what is not needed afterward. The elimination saves work at execution, not at compilation. If the query needs all columns, there is nothing to remove: then the view ran just as fast as the flat query, but compiled about twice as long.

View chain with foreign keysView chain without foreign keysFlat query, only the joins neededTables in the plan12222232425282921621720369121518table references; number of levels on the leftCompile time1221234147828162021650621010203040506070milliseconds; number of levels on the left
View chain with foreign keysView chain without foreign keysFlat query, only the joins neededTables in the plan1222223242528292162172061218table references per number of levelsCompile time122123414782816202165062103570milliseconds per number of levels
A query that needs only two columns from a chain of views. With foreign keys the optimizer removes every unneeded join, and the plan is as small as the flat query’s. Compile time still grows with each level: everything is expanded first, then removed. SQL Server 2022, median, 8 vCPUs, synthetic data; 2017, 2019 and 2025 show the same pattern.

In production, compilation happens only when no suitable plan is in the cache. For a query that runs a thousand times with the same plan, 50 ms is a one-time cost. It becomes noticeable for queries that are recompiled often, for example with changing literals in the SQL text or with OPTION (RECOMPILE).

In the lab, SQL Server 2025 compiled most queries for longer than 2022, depending on the query by 1.1 to 1.6 times. Only the deepest diamonds without foreign keys (see below) compiled faster on 2025. We did not investigate the reason.

When the search gets too big

The optimizer does not try out all possible plans. It works in stages and has a budget for each query that depends on its size. When the budget is used up, it takes the best plan found so far. The actual plan then shows:

  • StatementOptmLevel = FULL and StatementOptmEarlyAbortReason = TimeOut, in the graphical plan “Reason For Early Termination: Time Out”.

This is not an error and not an abort of the query. The plan found can be good. It only means that the optimizer did not check all possibilities, and that small changes to data or statistics can lead to a completely different plan.

In the lab, TimeOut came early and independently of views. Even the query written flat on 9 tables ended with it on all four versions. In the view chain without foreign keys, it occurred from 8 levels on, with foreign keys never, because only two tables remained after the elimination. What matters, then, is how many tables the optimizer has to arrange in the end, not how the query is written. A TimeOut alone is therefore not a finding but a hint to look more closely.

Microsoft describes real errors for very large expanded queries:

  • 8623: The optimizer finds no plan at all (“ran out of internal resources”).
  • 8632: The expanded expression exceeds an internal limit.

In the lab, neither occurred, not even for queries that contained more than 500 table references after expansion and compiled for over three minutes. The limit was time, not an error. Anyone waiting for an error message to recognize a problem with nested views may therefore wait in vain.

Views used more than once multiply the work

It becomes especially expensive when a view uses another view more than once, for example for a comparison with the previous year. Each use is expanded individually. If another view builds on it and does the same, the table references double with every level. You cannot see this in the source text; the inventory on the overview page does not show it either. Only the execution plan shows how often the same table is read.

In the lab, each level of this diamond joined the view below it to itself on the order number. The result stays the same, one row per order:

The diamond: each level uses the one below twice

vw_R1: orders and one dimension
CREATE VIEW dbo.vw_R1 ASSELECT o.OrderId, d1.Name AS D1Name, o.AmountFROM dbo.Orders AS oJOIN dbo.D1 AS d1 ON o.D1Id = d1.Id;
vw_R2: the same view twice
CREATE VIEW dbo.vw_R2 ASSELECT a.OrderId, a.D1Name, a.Amount + b.Amount AS AmountFROM dbo.vw_R1 AS aJOIN dbo.vw_R1 AS b ON a.OrderId = b.OrderId;   -- the same view a second timeGO -- vw_R3 joins vw_R2 to itself, vw_R4 then vw_R3, … up to vw_R9CREATE VIEW dbo.vw_R3 ASSELECT a.OrderId, a.D1Name, a.Amount + b.Amount AS AmountFROM dbo.vw_R2 AS aJOIN dbo.vw_R2 AS b ON a.OrderId = b.OrderId;

Level 2: orders 2 times in the plan · level 9: 256 times, compilation 219 s

How SQL Server expands SELECT … FROM dbo.vw_R3 before optimization
vw_R3
├── vw_R2 (a)
│   ├── vw_R1 (a)  →  Orders, D1
│   └── vw_R1 (b)  →  Orders, D1
└── vw_R2 (b)
    ├── vw_R1 (a)  →  Orders, D1
    └── vw_R1 (b)  →  Orders, D1

Level 4:     8 × Orders
Level 6:    32 × Orders
Level 9:   256 × Orders

Level 3: orders 4 times in the plan · every further level doubles it

The same result for level 3, without views
SELECT o.OrderId, d1.Name AS D1Name,       o.Amount * 4 AS Amount   -- 2 to the power of (level − 1), here level 3FROM dbo.Orders AS oJOIN dbo.D1 AS d1 ON o.D1Id = d1.IdWHERE o.OrderId BETWEEN 1000 AND 1100;

Orders 1 time in the plan · compilation a few milliseconds at every level

Code as in the lab, schema unified as dbo. Results on SQL Server 2022 with foreign keys, narrow query, median.

The optimizer did not merge the duplicate references, not even with foreign keys. For the same narrow query as above on SQL Server 2022:

LevelsOrders in the planCompile time with / without foreign keysRun time with foreign keys
48 times69 / 171 ms10 ms
632 times0.8 / 3.6 s3 ms
764 times5.5 / 54 s6 ms
8128 times32 / 133 s10 ms
9256 times219 s / not measured160 ms
Compilation with foreign keysCompilation without foreign keysExecution with foreign keys1 ms10 ms100 ms1 s10 s100 s1000 slab cut-off: 300 s11×22×34×48×516×632×764×8128×9256×levels of the diamond; below: how often the fact table appears in the plan219 s133 s160 ms
Compilation with foreign keysCompilation without foreign keysExecution with foreign keys1 ms10 ms100 ms1 s10 s100 s1000 slab cut-off: 300 s11×234×4516×6764×89256×levels; below: fact table in the plan219 s133 s160 ms
A view that uses the one below it twice, level upon level: with each level the number of times the fact table appears in the plan doubles. Compilation climbs from milliseconds to more than three minutes, while execution stays below 20 milliseconds up to level 8. Without foreign keys, level 9 was not measured. Logarithmic scale, SQL Server 2022, median, 8 vCPUs, synthetic data.

With foreign keys, from six levels on every further level cost roughly six to seven times as much compile time. At eight levels, compilation without foreign keys used about 800 MB of memory, and at nine levels with foreign keys about 1 GB. Execution of the narrow query, on the other hand, stayed small because it read only 101 orders. If the query has to read all the data, execution grows too: grouped over all orders, it ran for 70 seconds at eight levels with foreign keys and 212 seconds without. The flat query with the same result took 28 ms.

The limit differed somewhat by version. SQL Server 2019 and 2022 still compiled nine levels with foreign keys, 2025 eight. With the narrow query, SQL Server 2017 hit the 300 seconds already at six levels with foreign keys, although the newer versions compiled the same level in under a second. We did not investigate the reason.

How to proceed

  1. Find deep views. The inventory shows nesting depth and the tables behind it. It does not detect views that are used more than once.
  2. Get the actual plan of the slow query. Check CompileTime and CompileCPU, StatementOptmEarlyAbortReason, and how many tables the plan reads. If the same table appears many times in the plan although the query logically needs it once, a view has been expanded multiple times.
  3. Check foreign keys. Untrusted foreign keys (sys.foreign_keys.is_not_trusted = 1) prevent join elimination. ALTER TABLE … WITH CHECK CHECK CONSTRAINT … makes them checked again; this reads the whole table and belongs in a maintenance window.
  4. Flatten the layers. A query that needs only three tables should not go through a view with twelve. For reports, a separate flat view is often worthwhile.
  5. Split deliberately where necessary. An intermediate result in a temporary table breaks the optimization into smaller tasks. A CTE or subquery does not: it is expanded just like a view. What staging in TempDB costs is shown in the TempDB hub.

Sources

More on abstraction: T-SQL functions · Back to the overview: The Paradox of Abstraction

More on abstraction