Transparency & Reproducibility

SQL Lab: Benchmark Tables & Test Setup

Empirical evidence instead of guesswork: This page shows the measurements, data tables, plan operators, and the pedagogical construction of the 10-million-row test setup. Documented step-by-step so developers, DBAs, and technical leaders can inspect and apply these mechanics in their own systems.

1. How the test was constructed ↓ 2. Index benchmark matrix (Levels 0–5) ↓ 3. Ten deep benchmark series ↓
Engineering Guide

How the benchmark was constructed – and what engineers learn from it

A small table with 10,000 rows forgives almost every architectural flaw: It fits entirely into RAM, and even a missing index only costs a few unnoticeable milliseconds. True database engineering begins when tables grow into millions of rows.

Design Principle 1

Why 10 million synthetic rows?

Professional discretion is absolute: Client production data never leaves client infrastructure. To provide engineering teams and leadership with rigorous empirical proof, I generated a synthetic corporate transactional dataset (orders and ledger events).

10 million rows force the SQL Server engine to traverse real physical I/O paths. The clustered index covers roughly 350,000 8-KB data pages (~2.8 GB). Here we can observe exactly how the query optimizer behaves when data volume exceeds CPU cache limits.

Design Principle 2

The test table and schema

The schema mirrors a typical enterprise transactional record:

CREATE TABLE dbo.KundenBewegung (
    Id             bigint IDENTITY(1,1) NOT NULL,
    Nachname       varchar(50)  NOT NULL,
    Vorname        varchar(50)  NOT NULL,
    Stadt          varchar(50)  NOT NULL,
    GeschaeftsJahr smallint     NOT NULL,
    Umsatz         decimal(12,2) NOT NULL,
    Bemerkung      varchar(200) NULL,
    CONSTRAINT PK_KundenBewegung PRIMARY KEY CLUSTERED (Id)
);

Data distribution: Common surnames (~270k matches for top names), realistic city cardinalities, and financial years spanning 2018 to 2026.

Design Principle 3

The test query: Why this specific combination of filters and columns?

In production applications, users rarely filter by a single column. A standard enterprise reporting or ERP query reads:

SELECT Nachname, Vorname, Stadt, GeschaeftsJahr, Umsatz
FROM dbo.KundenBewegung
WHERE Nachname = @Nachname
  AND Stadt = @Stadt
  AND GeschaeftsJahr >= @GeschaeftsJahr;

The architectural intent behind this query structure:

Key Concept

The “Tipping Point”: Why Level 1 is ignored

In Level 1, an index is created on only one column: CREATE INDEX IX_Nachname ON KundenBewegung(Nachname). Many engineers expect the query to speed up. Yet SQL Server completely ignores the index and continues executing a full Table Scan!

The mathematical reason: Searching for a common name produces 271,384 matching rows. Seeking on IX_Nachname would locate the pointers quickly, but would then require 271,384 random I/O bookmark lookups into the clustered index to fetch Vorname, Stadt, and Umsatz. Because the entire table only spans 350,000 pages, the optimizer calculates: 271,000 random page lookups cost more CPU and disk time than scanning all 350,000 pages sequentially in one go. This is the Tipping Point.

The Objective I/O Currency

Why “Logical Reads” are central alongside execution plan, CPU and wait stats

Latency in milliseconds fluctuates depending on CPU clock rate, virtual machine throttling, background jobs, and operating system scheduling.

For a query’s data access, Logical Reads (8-KB pages accessed) provide a reproducible metric for a given execution plan and dataset – complemented by execution plan operators, CPU time, memory grants and lock/wait statistics. Whether pages reside in RAM or on NVMe storage: the number of buffer pool page accesses remains the same. An optimization that slashes logical reads from 350,000 down to 11 was faster by orders of magnitude in the lab. Fewer reads mean less I/O work; the exact time factor depends on the hardware.

Lab Benchmarks · Raw Metrics

Full Index Benchmark Matrix (Levels 0 to 5)

Measured automatically via PowerShell and T-SQL harness. Cold cache isolated prior to every run via CHECKPOINT; DBCC DROPCLEANBUFFERS;. Execution plans pre-compiled. All values represent averages of multiple benchmark runs (Status: 15 Oct 2026).

Baseline (Level 0 · Table Scan) 1,906 ms · 349,726 Reads
Optimised (Level 4 · Covering Index) 5.3 ms · 11 Reads
Efficiency Factor ×358 faster (×31,792 less I/O)
Comprehensive benchmark per index level across 1M and 10M rows
Level & Definition Duration
1M rows
Duration
10M rows
Logical Reads
10M rows
Additional indexes
(10M rows)
Execution Plan Operators
Level 0: No Index
Only PK on Id
192 ms 1,906 ms 349,716 Reads – Clustered Index Scan, Parallelism (Gather Streams)
Level 1: Index on 1 Column
IX(Nachname)
178 ms (×1.1) 1,843 ms (×1.0) 349,667 Reads (×1.0) 409 MB Clustered Index Scan (Optimizer abandons index due to Tipping Point)
Level 2: Two Single Indexes
IX(Nachname), IX(Stadt)
60 ms (×3.2) 126 ms (×15.1) 3,866 Reads (×90.5) 855 MB 2× Index Seek + Merge Join + Key Lookup (expensive steps)
Level 3: Composite Index
IX(Nachname, Stadt, Jahr)
32 ms (×6.0) 71 ms (×26.8) 2,686 Reads (×130) 625 MB Index Seek + Key Lookup (Nested Loops for payload columns)
Level 4: Covering Index (INCLUDE)
IX(...) INCLUDE(Vorname, Umsatz)
5.1 ms (×37.5) 5.3 ms (×358) 11 Reads (×31,792) 1,159 MB Pure Index Seek (0 Key Lookups, no parallelism needed)
Level 5: Filtered Covering Index
WHERE GeschaeftsJahr >= 2024
3.2 ms (×59.4) 5.4 ms (×354) 5 Reads (×69,943) 86 MB Pure Index Seek (Index footprint is only 7.4% of Level 4)
Important Engineering Caveat on Level 5 (Filtered Indexes & Parameter Sniffing):

Filtered indexes conserve enormous storage (86 MB vs 1,159 MB). However: If the query is executed with parameters from C#/.NET (e.g. WHERE GeschaeftsJahr >= @year), SQL Server cannot use the filtered index at compilation time because the runtime parameter could fall outside the filter boundary! Without OPTION (RECOMPILE), the query immediately falls back to a Full Table Scan with 349,538 Reads (1,878 ms).

Deep Benchmarks

Ten architectural bottlenecks profiled in the lab

Missing indexes are just the beginning. Below are exact benchmark measurements for common development, ORM, and schema traps:

Benchmark 1 · 10M rows

Collation & Implicit Data Type Conversion

When .NET / Entity Framework passes parameters as Unicode (nvarchar) against a varchar column:

Matching
3 Reads · 0 ms CPU
nvarchar
27,469 Reads · 3,011 ms CPU

Root cause: Under SQL Server Data Type Precedence, nvarchar outranks varchar. The engine cannot convert the parameter; it must convert every single table row on the fly (Plan warning: CONVERT_IMPLICIT). Index seeks become impossible; CPU burns for 2.4 seconds.

Benchmark 2 · 10M rows

Set Passing (IN Lists) & Plan Cache Bloat

Comparing dynamic concatenated strings with Table-Valued Parameters (TVP) for 2,000 IDs:

TVP
222 ms · 0 ms Compile
IN String
589 ms · 185 ms Compile

Root cause: Dynamically constructed IN (1, 2, 3...) queries flood the plan cache with single-use plans. Across 50,000 queries, ad-hoc execution seized 781 MB of RAM in the plan cache, while parameterized TVP required only 0.1 MB.

Benchmark 3 · 1M Child / 50k Parent rows

Natural Composite Key vs. Surrogate int Key

A 3-part natural text key (56 characters) compared to a slim int surrogate key:

int Key
65 MB · 471 ms Join
Text Key
287 MB · 1,142 ms Join

Root cause: The natural composite key duplicates into every foreign key and secondary index across all child tables. Storage expands by 4.4×, exerting persistent buffer pool pressure.

Benchmark 4 · 10M rows

SARGability: Leading Wildcards (LIKE '%berg')

Suffix search on company names: Standard scan vs. computed column with REVERSE():

REVERSE
43 Reads · 6.3 ms
LIKE '%...'
49,022 Reads · 3,890 ms

Root cause: LIKE '%berg' cannot seek B-trees. Adding a computed column FirmaRev AS REVERSE(Firma) with an index converts a non-SARGable suffix search into a high-speed prefix seek LIKE 'greb%': ×617 faster.

Benchmark 5 · 1M rows

Database boundaries: collation mismatch

Cross-database queries:

Matching
66 Reads · Index Seek
Mismatch
7,882 Reads (Factor ×119)

Root cause: If the collation of a remote database differs, SQL Server pulls all rows locally and compares them row-by-row. Remote index seeks become impossible.

Benchmark 6 · 1M rows

Normalisation (3NF) & Data Quality

Raw string columns in transactional tables vs. indexed lookup tables:

Normalised
33 ms · Validation: 4.3 ms
Plain Text
1,518 ms · Validation: 521 ms

Root cause: Filtering on a compact lookup table (5,000 cities) executes in near-zero time before joining via a lean int key – 46.4× faster, and the model prevents differing spellings of the same city.

Benchmark 7 · 10M customers, 40M line items

Stored procedure vs. view: where the filter is applied

We look for the revenue of the 100 customers in one city, with suitable indexes. The logic sits once in a view and once in a procedure without parameters whose result is filtered afterwards (INSERT … EXEC):

View, outer WHERE
52 ms · 3,408 reads
Multi-statement function
53,095 ms (factor ×1,029)
Procedure, filtered outside
134,172 ms · 44,110,987 reads (factor ×2,600)

Root cause: The view is expanded into the query, so the city filter moves inside and uses the index. The procedure computes all 10 million customers first and is filtered only afterwards. The procedure itself is not the problem: with a parameter it takes 54 ms, as fast as the view. Client times with a cold cache.

Benchmark 8 · 100M bookings

Removing one month: DELETE vs. truncating a partition

The same bookings over 120 months, stored by booking number, by date, and partitioned by month. One month (833,333 rows) is removed:

Truncate partition
8.5 ms (TRUNCATE … WITH (PARTITIONS))
DELETE, by date
14,082 ms (factor ×1,657)
DELETE, no date index
67,298 ms (factor ×7,917)

Root cause: DELETE logs every row and maintains every index. Truncating a partition only deallocates its pages. Prerequisite: the month lies in exactly one partition and all indexes are aligned. Only the delete statement was measured, inside a transaction that was rolled back afterwards.

Benchmark 9 · 100M bookings

Partitioning does not make queries faster by itself

The same three tables as in benchmark 8, this time two queries: the total of one month and the total of one account across all years:

Month, by date
173 ms
Month, partitioned
244 ms (no date index: 11,077 ms)
Account, not partitioned
45 ms
Account, partitioned
285 ms

Root cause: For the monthly total an index by date is enough; in this measurement partitioning was even slightly slower. The account query does not know the month and has to search all 120 partitions. Partitioning is a tool for data maintenance (benchmark 8), not a substitute for the right index.

Benchmark 10 · 100M customers, 400M line items

Three-table join at 100 million customers

Customer, order and line item; we look for the 400 line items of the customers in one city. No index on the foreign keys vs. indexes on city and foreign keys (with INCLUDE):

With indexes
66 ms · 3,144 reads
Primary keys only
41,982 ms · 6,843,764 reads (factor ×632)

Root cause: Without an index SQL Server reads all 400 million line items to find 400 – 2,177 times as many pages. Three subqueries per customer instead of the join: 50,737 ms vs. 232 ms (factor ×219). Forced nested loops without an index were not measured at this size.

Would you like this deterministic profiling applied to your slowest queries?

In a non-binding initial call, we examine your bottlenecks. I demonstrate how minimal-impact measurement scripts expose root causes – with as little load on live operations as possible.

Book an initial call →