Week 4 · Load to the warehouse › Day 26
Day 26. T-SQL for people who run warehouses
By the end of today you can
- Choose clustered, nonclustered and columnstore indexes for fact and dimension tables
- Read an execution plan and spot scans, key lookups and bad estimates
- Explain locking, blocking and why RCSI makes reports and loads coexist
- Use table partitioning and temporal tables where they pay off
1. Why this matters
SQL was your stated biggest gap. Writing SELECT is the start; a five-year BI developer also knows why a query is slow, why a load blocked a report, and which index fixes it. This is the day that separates "knows SQL" from "runs a SQL warehouse".
2. Indexes in one table
| Index | Stores | Best for | Typical use |
|---|---|---|---|
| Clustered rowstore (B-tree) | The table itself, sorted by key | Range seeks on the key | Dimensions: dim_store(store_key) |
| Nonclustered rowstore | A sorted copy of some columns + pointer | Selective lookups, include columns to cover queries |
fct(business_date) include (revenue) on small facts |
| Clustered columnstore (CCI) | The table, by column, compressed in rowgroups of ~1M rows | Large scans and aggregations | Fact tables over a few million rows |
| Nonclustered columnstore | Columnar copy on a rowstore table | Mixed OLTP + analytics | Operational tables also used for reports |
create table mart.fct_qsr_lines (
business_date date not null, restaurant_key int not null, menu_item_key int not null,
channel varchar(12) not null, qty smallint not null, revenue decimal(12,2) not null, order_id varchar(20) not null
);
create clustered columnstore index cci_fct_qsr_lines on mart.fct_qsr_lines;
Load columnstore in batches of at least ~102,400 rows so data goes straight into compressed rowgroups; tiny trickle inserts sit in the delta store.
3. Reading a plan
In SSMS (or VS Code with the MSSQL extension), show the actual execution plan (Ctrl+M). Read right to left. Look for:
- Table/Index Scan on a big table where you expected a seek.
- Key Lookup repeated thousands of times: add
includecolumns. - Estimated vs actual rows far apart: stale statistics or a non-sargable predicate.
- Spills to tempdb on sorts and hashes: memory grant too small.
Non-sargable means the predicate hides the column from the index: where year(business_date) = 2026 can't seek; where business_date >= '2026-01-01' and business_date < '2027-01-01' can.
set statistics io, time on;
select restaurant_key, sum(revenue) from mart.fct_qsr_lines
where business_date >= '2026-09-01' and business_date < '2026-10-01' group by restaurant_key;
4. Locks, blocking and RCSI
By default, SQL Server's READ COMMITTED uses locks: a big DELETE in a load can block Power BI's refresh queries until it commits. Read Committed Snapshot Isolation (RCSI) makes readers see the last committed version instead of waiting. It's on by default in Azure SQL Database; on SQL Server you enable it with alter database ... set read_committed_snapshot on.
Maths and principle
Principle: an index turns a linear search into a logarithmic one; a column store turns "read all columns" into "read the ones you need, compressed".
Equation: B-tree seek cost \(\approx \log_f N\) page reads (fan-out \(f\) in the hundreds), so \(N = 10^9\) rows needs about 4–5 levels. Full scan cost \(\propto N\). Columnstore scan \(\propto N \cdot \frac{|Q|}{|C|} \cdot \frac{1}{\text{compression}}\), with rowgroup elimination on top.
Analogy: a phone book (sorted, jump to the letter) vs reading every page. Where it breaks: indexes cost writes. Every insert updates every index, so an over-indexed fact table loads slowly.
Try it: fan-out 400, how many levels for 64 million rows? (400³ = 64 million: about 3 levels.)
5. Partitioning and temporal tables
Table partitioning (by month on business_date) gives partition elimination and lets you switch a fully loaded staging table into place instantly. It's worth it on facts with hundreds of millions of rows; below that, columnstore rowgroup elimination usually suffices.
System-versioned temporal tables keep history automatically: every update writes the old row to a history table with validity times. Great for "what did the campaign table say on 1 Sept?" and as a cheap SCD for small dimensions.
create table mart.dim_campaign (
campaign_key int identity primary key, campaign_id varchar(10) not null unique,
client varchar(50), channel varchar(20), objective varchar(20),
valid_from datetime2 generated always as row start, valid_to datetime2 generated always as row end,
period for system_time (valid_from, valid_to)
) with (system_versioning = on (history_table = mart.dim_campaign_history));
select * from mart.dim_campaign for system_time as of '2026-09-01';
The 5-year lens
Learn to answer "why is the dashboard slow?" in a fixed order: (1) is it the model (Power BI DirectQuery issuing 40 queries per visual)? (2) is it one query (capture it, run with statistics io, read the plan)? (3) is it contention (a load running at the same time: check sys.dm_exec_requests for blocking)? (4) is it resources (DTU/vCore at 100% in the portal)? Most of the time it's 1 or 2. Query Store in Azure SQL shows regressed queries over time; turn it on and look at it weekly.
Lab
- Load 120 days of QSR lines (scale up: run the generator with more days if needed) into a rowstore heap, then add a CCI. Compare
statistics ioreads and elapsed time for 4 report queries. - Write one non-sargable and one sargable version of a date filter; compare plans; screenshot both.
- Create a blocking situation: in one session
begin tran; delete from mart.fct_qsr_lines where business_date='2026-09-14';(don't commit). In another, run a report query. On the local container with RCSI off it waits; turn RCSI on and retry. (Azure SQL already has it on.) - Convert
dim_campaignto a temporal table. Change a campaign's objective; queryas ofbefore and after. - Write 10 practice queries on the ads mart: ROAS by channel, week-over-week change, top campaign per client (window
rank), 7-day rolling spend, first day each campaign exceeded $1,000 cumulative. Save them insql/practice.sqlwith results.
Ship
feat(sql): columnstore fact, plan comparisons, temporal dimension, practice queries.
Check yourself
1. A 200M-row fact table is used only for aggregations. Best primary storage?
Columnstore compresses and scans only needed columns.
2. Which filter can use an index on business_date?
Wrapping the column in a function makes the predicate non-sargable.
3. Power BI refreshes stall while the nightly load deletes rows. What setting addresses this?
Readers see the last committed version instead of waiting on locks.