Overview
A query that runs fine on a thousand test rows can become unusably slow on a few million real ones, and the reason is almost always the same: without an index on the column being filtered, SQL Server has no choice but to read every single row in the table — a table scan — just to find the handful that match. This project deliberately builds that slow query first, measures exactly how much work it does using `SET STATISTICS IO ON`, reasons through what its execution plan is doing conceptually, and then fixes it with a single targeted index — the same diagnose-then-fix workflow a DBA runs against a real production slowdown.
By the end of this tutorial you will have an `orders` table with enough rows to make the difference measurable, a deliberately slow query filtering on a non-indexed `customer_email` column, a read of its logical I/O cost via `SET STATISTICS IO ON`, an explanation of why that query causes a table scan (and what an index seek looks like instead), and a `CREATE INDEX` that turns the scan into a seek — with the before/after read counts to prove it.
- An `orders` table populated with enough sample rows for the I/O difference to be visible.
- A deliberately slow query filtering on `customer_email`, a column with no index.
- `SET STATISTICS IO ON` output showing the logical reads that query costs before any fix.
- Conceptual reasoning about a table scan vs. an index seek, since a graphical execution plan can't be embedded here.
- A `CREATE INDEX` on `customer_email` and the reduced logical reads it produces afterward.
Prerequisites
- CREATE TABLE basics in T-SQL and basic INSERT/SELECT statements.
- The general idea of an index — a data structure that lets the database find rows without reading every one.
- What `WHERE` filtering does, and that its performance depends on how the underlying data is organized.
- A first look at query execution — the idea that SQL Server picks a "plan" for how to run a query before running it.
- Comfort running administrative commands (`SET STATISTICS IO ON`, `CREATE INDEX`) in SQL Server Management Studio or Azure Data Studio.
Project Structure
Just one table again, like the analytics project — performance tuning is a querying and indexing skill applied to data you already have, not a new schema shape. The table needs enough rows for the scan-vs-seek difference to actually be measurable, so this step deliberately generates several thousand rows instead of a handful.
| Table | Purpose | Key Columns |
|---|---|---|
| orders | One row per order, at a large enough scale to make I/O differences visible | order_id (PK), customer_email (no index, initially), order_date, total_amount |
Step 1: Create and Populate the Orders Table
`customer_email` is deliberately left without any index after table creation — a primary key alone does not help a query that filters on a completely different column. A `CROSS JOIN` between two small system-view row sources is a common T-SQL trick for quickly generating many rows of sample data without writing thousands of individual `INSERT` statements by hand.
CREATE TABLE orders ( order_id INT IDENTITY(1,1) PRIMARY KEY, -- Indexed automatically: PRIMARY KEY creates a clustered index by default customer_email VARCHAR(100) NOT NULL, -- No index on this column yet -- that's the problem this project fixes order_date DATE NOT NULL, total_amount DECIMAL(10,2) NOT NULL);
-- Generate ~50,000 sample rows using a CROSS JOIN of system catalog views,-- a common T-SQL trick for bulk sample data without writing INSERTs by handINSERT INTO orders (customer_email, order_date, total_amount)SELECT TOP (50000) 'customer' + CAST(ABS(CHECKSUM(NEWID())) % 5000 AS VARCHAR(10)) + '@example.com', DATEADD(DAY, -(ABS(CHECKSUM(NEWID())) % 365), GETDATE()), CAST(RAND(CHECKSUM(NEWID())) * 500 AS DECIMAL(10,2))FROM sys.all_objects aCROSS JOIN sys.all_objects b;
-- Add one specific, known email so Step 2's query has a predictable row to findINSERT INTO orders (customer_email, order_date, total_amount)VALUES ('priya.sharma@example.com', '2026-07-15', 129.99);Step 2: Run the Slow Query and Measure It
`SET STATISTICS IO ON` makes SQL Server report how much work each query actually did — specifically, the number of 8KB pages it had to read from the buffer cache or disk. That number, "logical reads," is a concrete, comparable measurement of query cost that doesn't require a graphical execution plan viewer to interpret: fewer logical reads means less work, full stop.
SET STATISTICS IO ON;
SELECT order_id, customer_email, order_date, total_amountFROM ordersWHERE customer_email = 'priya.sharma@example.com';
SET STATISTICS IO OFF;Click Run to see what this code prints.
693 logical reads to find one matching row out of 50,001 is the table scan cost showing up directly in numbers: SQL Server had to read essentially every page of the table, because nothing tells it where rows matching that email might be.
Step 3: Reason About the Execution Plan
A graphical execution plan can't be embedded in a tutorial like this one, but the two operators that matter here can be reasoned about directly, and recognizing them by name is most of what matters when reading a real plan later.
With no useful index, SQL Server's only option is to read every single row in `orders`, from the first page to the last, checking each one against `WHERE customer_email = ...`. Cost scales linearly with table size: double the rows, and a table scan roughly doubles its logical reads too. This is the operator you want to see disappear from a plan on a large table.
With an index on `customer_email`, SQL Server can navigate the index's B-tree structure directly to the matching value — similar to how a phone book lets you jump straight to "Sharma" instead of reading every entry from "Aaron." Cost scales roughly with the *depth* of the index, not the size of the table, which is why an index seek stays fast even as a table grows into the millions of rows.
Step 4: Create the Right Index
A nonclustered index on `customer_email` builds exactly the lookup structure the query in Step 2 was missing, without disturbing the existing clustered index on `order_id`. `INCLUDE`d columns are stored alongside the index key but are not part of the sort order — adding the columns this specific query selects means SQL Server can answer it entirely from the index itself, never touching the underlying table at all (a "covering index").
CREATE NONCLUSTERED INDEX IX_orders_customer_emailON orders (customer_email) -- The column the slow query filters onINCLUDE (order_date, total_amount); -- Covers the rest of the SELECT list too, avoiding a lookup back to the tableStep 5: Re-Run and Compare
The exact same query as Step 2, run again after the index exists. The logical reads collapse from the hundreds down to single digits — the query now performs an index seek straight to the matching row (plus the small number of pages needed to traverse the index's B-tree), instead of scanning the whole table.
SET STATISTICS IO ON;
SELECT order_id, customer_email, order_date, total_amountFROM ordersWHERE customer_email = 'priya.sharma@example.com';
SET STATISTICS IO OFF;Click Run to see what this code prints.
Logical reads dropped from 693 to 3 — over a 200x reduction — for exactly the same query and exactly the same data, purely from adding one targeted index. In the execution plan, this shows up as the Table Scan operator being replaced by an Index Seek, with the estimated and actual cost dropping to match.
Complete Schema
The full schema and the fix, ready to run top to bottom.
CREATE TABLE orders ( order_id INT IDENTITY(1,1) PRIMARY KEY, customer_email VARCHAR(100) NOT NULL, order_date DATE NOT NULL, total_amount DECIMAL(10,2) NOT NULL);
INSERT INTO orders (customer_email, order_date, total_amount)SELECT TOP (50000) 'customer' + CAST(ABS(CHECKSUM(NEWID())) % 5000 AS VARCHAR(10)) + '@example.com', DATEADD(DAY, -(ABS(CHECKSUM(NEWID())) % 365), GETDATE()), CAST(RAND(CHECKSUM(NEWID())) * 500 AS DECIMAL(10,2))FROM sys.all_objects aCROSS JOIN sys.all_objects b;
INSERT INTO orders (customer_email, order_date, total_amount)VALUES ('priya.sharma@example.com', '2026-07-15', 129.99);
CREATE NONCLUSTERED INDEX IX_orders_customer_emailON orders (customer_email)INCLUDE (order_date, total_amount);Sample Queries
Two more queries worth trying against the same table: a range filter that the new index also helps with, and a query the index does *not* help, to make clear an index is not a universal speedup.
-- A date-range filter still benefits from an index, though a separate index on-- order_date (not built in this project) would help it even more than the email index doesSET STATISTICS IO ON;
SELECT COUNT(*) AS orders_last_30_daysFROM ordersWHERE order_date >= DATEADD(DAY, -30, GETDATE());
SET STATISTICS IO OFF;| orders_last_30_days |
|---|
| ~4100 |
-- A query filtering on total_amount gets NO benefit from IX_orders_customer_email ---- an index only speeds up queries that filter, join, or sort on its own key column(s)SET STATISTICS IO ON;
SELECT order_id, customer_email, total_amountFROM ordersWHERE total_amount > 450.00;
SET STATISTICS IO OFF;| note |
|---|
| Logical reads stay near 693 here too -- this query would need its own index on total_amount to see the same improvement. |
Extend This Project
- Add a second index on `order_date` and re-run the date-range query from Sample Queries to compare its logical reads.
- Use `sys.dm_db_missing_index_details` to see what SQL Server itself would have recommended.
- Try `SET STATISTICS TIME ON` alongside `STATISTICS IO` to compare CPU/elapsed time, not just page reads.
- Rebuild `IX_orders_customer_email` with a different fill factor and observe the effect on page splits under heavy inserts.
- Add a filtered index (`WHERE order_date >= '2026-01-01'`) to see how narrowing an index's scope shrinks it further.
Summary
You measured a real query cost with `SET STATISTICS IO ON` instead of guessing, reasoned through why a missing index forces a table scan (and what an index seek does differently), and then confirmed the fix numerically: logical reads dropped by more than 200x after adding one targeted, covering index. That diagnose-measure-fix-verify loop — not just "add an index and hope" — is the actual discipline behind SQL Server performance tuning, and it works the same way whether the table has 50,000 rows or 50 million.