rowstore / B-tree columnstore delta store 1 / 20

SQL Server & Azure SQL Database / Managed Instance

Columnstore,
from the inside

A storage engine walkthrough for engineers who already know B-trees: how rows become segments, what the delta store is really doing, and why batch mode changes the arithmetic.

One table. Two ways to cut it. ROWSTORE — the unit on disk is a row 10012026-04-02 C-771499.00Shipped 10022026-04-02 C-3189.50Shipped 10032026-04-03 C-77220.00Open 10042026-04-03 C-057.25Open Read one order: 1 page. Sum a column across 100M orders: read every byte of every row. COLUMNSTORE — the unit on disk is a column 10011002 10031004 04-0204-02 04-0304-03 C-77C-31 C-77C-05 149989.5 2207.25 ShipShip OpenOpen Sum a column: read one stripe. Read one order: reassemble it from five stripes.

The whole idea, in one refactor you have already done

Columnstore is struct-of-arrays applied to a database engine, plus aggressive per-array compression. Everything that follows — segments, dictionaries, batch mode — falls out of that one decision.

Rowstore: array of structs

// A row is contiguous. Loading one row is cheap.
record Order(
    int Id, DateOnly Date, int CustomerId,
    decimal Amount, string Status);

Order[] orders;

// SUM(Amount) touches Id, Date, CustomerId and
// Status too — they sit between the values you want.
decimal total = 0;
foreach (var o in orders) total += o.Amount;

Columnstore: struct of arrays

// A column is contiguous. Loading one column is cheap.
class OrderColumns {
    int[]      Id;
    DateOnly[] Date;
    int[]      CustomerId;
    decimal[]  Amount;
    string[]   Status;
}

// SUM(Amount) touches exactly one array.
decimal total = amountColumn.Sum();

Values in one array are the same type and similar magnitude, so they compress an order of magnitude better than mixed-type rows.

You can hand a tight Span<T> to a vectorised loop. SQL Server does exactly this — it calls it batch mode.

The cost: fetching a single whole row now means five random reads instead of one. That trade-off is the entire discussion.

Where rowstore actually puts your bytes

Everything in a heap or B-tree lives in an 8 KB page. Rows never span pages (except LOB overflow), and the page is the smallest unit of I/O the engine will read.

Page header — 96 bytes id, type, LSN, free Row 1001 · 2026-04-02 · C-77 · 1499.00 · Shipped Row 1002 · 2026-04-02 · C-31 · 89.50 · Shipped Row 1003 · 2026-04-03 · C-77 · 220.00 · Open free space grows down slot array — 2-byte offsets, grows up ← 8,192 bytes total · ~8,060 usable · 8 pages = 1 extent (64 KB)
  • The row is the atom. To read Amount you pull the whole page containing the whole row, including the forty columns you did not ask for.
  • Compression options are ROW and PAGE. PAGE gets prefix and dictionary compression within a single page — a tiny window, so typical gains are 2–4×.
  • Reads are page-granular, so a scan of a 40-column table costs the same whether your query needs 1 column or 40.
  • This is exactly the right shape for OLTP: fetch the order, update the order, lock the order.

A clustered index is the table, sorted, in a tree

seek for OrderId = 1003 root · 1 page level 2 1000 – 1999 2000 – 2999 3000 – 3999 level 1 1000–1499 rows 1500–1999 2000–2499 2500–2999 3000–3499 3500–3999 leaf level = the data pages, doubly linked in key order 3 logical reads to land on any single row

What it is brilliant at

Point lookups and narrow ranges. Tree height is logₖ(rows) with a huge fanout, so a billion-row table is still 4–5 reads deep. Range scans walk the linked leaf level sequentially.

Where it falls over

SUM(Amount) GROUP BY Region over 200M rows. No tree helps you — the engine reads every leaf page, materialises every row, and pushes them through the plan one at a time.

The covering-index treadmill

The rowstore answer to a slow analytic query is a nonclustered index that covers it. That works, once. It does not compose.

NCIX_Sales_Date_Region (Amount) key: OrderDate, Region → included: Amount → ClusterKey 2026-04-02 · EMEA · 1499.00 · 1001 2026-04-02 · APAC · 89.50 · 1002 2026-04-03 · EMEA · 220.00 · 1003 key lookup per row, if you need one more column clustered index leaf (the table) 1001 · all 40 columns 1002 · all 40 columns 1003 · all 40 columns every index is a second full copy of its columns, maintained on every write
  • Each new report needs a new column order, so you add another index. Ten reports, ten indexes, ten write amplifications on every INSERT.
  • A covering index is a sorted copy of its columns. Storage grows linearly with the number of query shapes you support.
  • Miss one column and you get a key lookup per qualifying row — which the optimiser will happily abandon in favour of a full scan around 1–2% selectivity.
  • Columnstore inverts this: one structure covers every column, so any projection is already covered.

Rowgroup → column segment → blob

A columnstore index chops the table horizontally into rowgroups of up to 1,048,576 rows (220), then slices each rowgroup vertically into one column segment per column. The segment is the compression unit and the I/O unit.

dbo.Sales · 3.1 billion rows · 5 of 40 columns shown rowgroup 01,048,576 rows seg: OrderId seg: OrderDate seg: CustomerId seg: Amount seg: Status … rowgroup 11,048,576 rows seg: OrderId seg: OrderDate seg: CustomerId seg: Amount seg: Status … rowgroups 2 … 2,957 each segment is compressed on its own → stored as a LOB blob, not as 8 KB data pages → min / max metadata kept beside it A 40-column table with 3,000 rowgroups holds 120,000 independently compressed, independently skippable segments.

1,048,576 is a hard ceiling, not a target. Anything smaller still works — it just leaves compression and elimination on the table.

Segments are LOB-allocated. That is why SET STATISTICS IO ON reports columnstore work under lob logical reads, and logical reads is often 0.

Reading three columns means fetching 3 segments per rowgroup, never 40. Projection pruning is free and automatic.

The fastest segment is the one you never read

Every segment stores the min and max of its values. Before touching a byte of data, the scan compares your predicate against that metadata and discards whole rowgroups. This is rowgroup elimination, and it is where most of the win comes from.

WHERE OrderDate >= '2026-04-01' AND OrderDate < '2026-05-01' rg 001-01 →01-28 rg 101-28 →02-19 rg 202-19 →03-14 rg 303-14 →04-11 rg 404-11 →05-06 rg 505-06 →05-30 rg 605-30 →06-22 rg 706-22 →07-15 6 rowgroups skipped — metadata only, zero data reads 2 read 6.3M rows on disk → 2.1M decompressed → ~440K after the residual filter
  • Elimination is only as good as your data order. Insert order determines rowgroup boundaries; a CCI does not sort for you.
  • Numeric, date and time types eliminate reliably. SQL Server 2022 and Azure SQL extended elimination to string, binary, uniqueidentifier and datetimeoffset with scale > 2.
  • Partitioning still helps: partition elimination happens first, then rowgroup elimination inside surviving partitions.
  • Two caveats on the 2022 string support: an existing index only benefits after a rebuild (ALTER INDEX REBUILD or DROP_EXISTING = ON), and elimination works for LIKE 'abc%' but never for LIKE '%abc'.
  • Segment elimination never applies to LOB types — the (max) lengths.
-- what the optimiser is reading before it reads data
SELECT c.name, s.segment_id, s.row_count,
       s.min_data_id, s.max_data_id, s.on_disk_size
FROM sys.column_store_segments s
JOIN sys.partitions p ON p.hobt_id = s.hobt_id
JOIN sys.columns  c ON c.object_id = p.object_id
                     AND c.column_id = s.column_id
WHERE p.object_id = OBJECT_ID(N'dbo.Sales')
  AND c.name = N'OrderDate'
ORDER BY s.segment_id;

Four transforms turn a column into a few kilobytes

Compression runs per segment, over a million values of one type. That is a far richer window than PAGE compression's 8 KB, which is why the ratios differ by an order of magnitude.

1 · encode value encoding: rebase and rescale numerics to the smallest int that fits. dictionary encoding for strings. 1499.00 → 149900 → 4 bytes 2 · reorder rows Vertipaq optimisation permutes rows inside the rowgroup to cluster repeats across all columns at once. C77 C31 C77 → C77 C77 C31 3 · RLE + bit-pack runs collapse to (value, count). Leftovers are bit-packed to the exact bit width needed. C77×412,880 → 6 bytes 4 · archival (opt-in) COLUMNSTORE_ARCHIVE runs an LZ-style pass on top. ~30% smaller again, noticeably slower to read. cold partitions only bytes for one OrderDate segment, 1,048,576 rows — illustrative, not measured 8.0 MB raw (8 bytes/value) 2.6 MB with PAGE compression on a rowstore ~0.2 MB as a columnstore segment

Row reordering is real and observable: has_vertipaq_optimization in the rowgroup DMV. It is skipped when a delta rowgroup moves into a CCI that has nonclustered indexes on it, and on memory-optimized tables.

A dictionary is capped at 16 MB in memory, one per long-string column (> 32 bytes) per rowgroup. A wide, high-cardinality nvarchar blows the cap and trims the rowgroup early — DICTIONARY_SIZE.

Ratios are workload-dependent, but 5–10× versus an uncompressed rowstore is the normal range, and much more on low-cardinality columns.

Why the same query costs 400× less

dbo.Sales · 3.1 billion rows · 40 columns · the query touches 3 of them and one month of data

SELECT s.Region, SUM(s.Amount) AS Revenue, COUNT_BIG(*) AS Orders
FROM   dbo.Sales AS s
WHERE  s.OrderDate >= '2026-04-01' AND s.OrderDate < '2026-05-01'
GROUP BY s.Region;

Clustered B-tree, PAGE compressed

  • No index on OrderDate → full scan of every leaf page.
  • Every page carries all 40 columns; 37 of them are dead weight.
  • ~310 GB read, ~40M logical reads, row-at-a-time through Filter and Hash Match.
  • Add a covering index and it drops — but you have now paid for a second sorted copy, and the next report needs a different one.

~310 GB

Clustered columnstore

  • Rowgroup elimination on OrderDate keeps ~90 of 2,958 rowgroups.
  • Only OrderDate, Region and Amount segments are fetched — 3 of 40.
  • Each segment arrives compressed 5–10× and often stays compressed through the aggregate.
  • SUM and COUNT are pushed into the scan operator itself.

~0.8 GB

Numbers are illustrative of the shape, not a benchmark. The three multipliers — fewer rowgroups, fewer columns, better compression — multiply together, which is why columnstore wins are usually two or three orders of magnitude rather than 2×.

Compressed data cannot be updated, so writes land somewhere else

You cannot poke a new value into an RLE-encoded, bit-packed segment. SQL Server's answer is a staging area in rowstore format — the delta store, which is an ordinary clustered B-tree hiding inside your columnstore index.

OPEN delta rowgroup, B-tree, uncompressed, accepting inserts right now 1,048,576 rows CLOSED full, sealed, read-only, waiting for the tuple mover tuple mover COMPRESSED real columnstore segments, min/max metadata, eligible for elimination compressed TOMBSTONE the delta rowgroup that was just compressed, now unreferenced — freed later bulk insert of ≥ 102,400 rows → straight to COMPRESSED A table can have many rowgroups in different states at once. Queries always read all of them: compressed segments in batch mode, delta rowgroups scanned as rowstore and stitched in. There is a fifth state, INVISIBLE (0): a columnstore rowgroup that is mid-compression. When compression finishes, a metadata switch flips it INVISIBLE → COMPRESSED and flips the source delta rowgroup CLOSED → TOMBSTONE. A COMPRESSED rowgroup whose rows have all been deleted also goes to TOMBSTONE.

What actually happens on INSERT, DELETE and UPDATE

INSERT bulk load, and ≥ 102,400rows in the batch? yes compressed rowgroup, written directly no appended to the open delta rowgroup one pass, no double write cheap per row, but it is a B-tree insert DELETE is the row in acompressed rowgroup? yes id added to the deleted-rows B-tree no physically removed from the delta B-tree the row stays on disk until rebuild and is filtered out on every read UPDATE columnstore row: mark deleted + insert new row into the delta store never edits in place, so it grows and fragments the index. A row still in the delta store is updated in place.

102,400 is the number to design around. Batch your ETL above it and you skip the delta store, the tuple mover, and the second write entirely.

Row-by-row INSERT from an ORM lands every row in a B-tree delta rowgroup. Functionally fine; you have just built a slow rowstore with extra steps.

Deletes are logical. A rowgroup that is 90% deleted still costs full I/O to read until a merge or rebuild reclaims it.

Two background processes keep index quality from rotting

Tuple mover

Wakes periodically, looks for CLOSED delta rowgroups, compresses each into columnstore segments and marks the delta rowgroup for deallocation. It only ever touches rowgroups that are already full — it will not help a delta rowgroup that is stuck at 40,000 rows.

Background merge

On Azure SQL Database, Azure SQL Managed Instance and SQL Server 2019+, a background merge task assists the tuple mover. It compresses small OPEN delta rowgroups that have sat around past an internal threshold, and merges COMPRESSED rowgroups that have accumulated a lot of deleted rows.

Up to SQL Server 2017 you had to run REORGANIZE yourself to merge small compressed rowgroups and reclaim deleted space. Microsoft now says index quality is maintained automatically on SQL Server 2019+, Azure SQL Database and MI, removing the need for periodic maintenance in most cases — but it is not instant, and it will not rescue a badly shaped load pattern.

delta rowgroups CLOSED · 1,048,576 CLOSED · 1,048,576 OPEN · 38,204 tuple mover backgroundmerge COMPRESSED COMPRESSED COMPRESSED · small fragmented over time 720K deleted 660K deleted 88K rows · trimmed merge 1 clean rowgroup ~748K live rows Space from deleted rows is only reclaimed when a merge or a rebuild rewrites the rowgroup.

Storage is half the story. Batch mode is the other half.

Classic SQL Server execution is an iterator tree: each operator calls GetRow() on its child, once per row, with a virtual dispatch and a bookkeeping structure per call. At 200 million rows that overhead dominates the actual work.

ROW MODE — one row per operator call Scan Filter Hash Match one row, then the next, then the next × 200,000,000 calls virtual dispatch + row structure each time CPU cache thrashes on every hop BATCH MODE — up to 900 rows per operator call Scan Filter Hash Match 900-row vector + qualifying bitmap × ~222,000 calls contiguous arrays — the engine detects and uses AVX2 / SSE4 a filter flips bits in a bitmap instead of copying rows many operators work on still-compressed values

The .NET analogy: replacing IEnumerable<T> + yield return per element with a loop over Span<T> that the JIT can vectorise.

Batch mode arrives automatically when a columnstore index is in the plan — that is the real reason a tiny, otherwise-useless NCCI can transform a query.

Batch mode on rowstore (SQL Server 2019+, Azure SQL) gives B-tree tables the same engine with no columnstore index. Needs compat level 150; toggle with BATCH_MODE_ON_ROWSTORE or the DISALLOW_BATCH_MODE hint. The optimiser still decides by heuristic.

The optimiser pushes work down into the scan

The less data that crosses an operator boundary, the better. Columnstore scans can evaluate predicates and even aggregates internally, against compressed data, before any rows are materialised.

rowgroup elimination metadata only 2,958 → 90 rowgroups segment read + predicate pushdown filters evaluated inside the scan, over compressed values 94M → 6.2M rows aggregate pushdown MIN / MAX / SUM / COUNT / COUNT(*) computed per rowgroup in the scan 6.2M → 90 partial aggregates plan Hash Match combines the partials 4 rows out
  • Aggregate pushdown covers MIN, MAX, SUM, COUNT, COUNT(*) — not distinct aggregates, not string columns, not virtual columns, and input and output types must fit in 64 bits. The aggregate must sit directly on the scan, with or without GROUP BY.
  • String predicate pushdown uses the segment dictionary: match the predicate against the dictionary once, then compare integer ids for the million rows. It does not apply to delta rowgroups (no dictionary), or when a dictionary exceeds 64K entries.
  • Bitmap filters from a hash join get pushed into the scan, so the fact-table scan skips non-joining rows before they are ever produced.
  • Adaptive joins (2017+/Azure) defer the hash-vs-loop decision until the build input is materialised — useful when a report's selectivity varies by parameter.
  • Memory grant feedback corrects an over- or under-sized grant on subsequent executions, which matters because batch mode hash tables want real memory.
  • Anything the scan cannot push down becomes a residual filter above it. That is fine, just visible as a bigger row count leaving the scan.
  • Microsoft's two pages disagree on AVG: the query-performance reference omits it, the what's-new page lists it. Treat AVG as unconfirmed and check your own plan.

Four things to check when it is slower than you expected

  • Actual Execution Mode on the scan. If it says Row, you lost batch mode — usually a scalar UDF, a cursor, or a data type the operator will not vectorise.
  • Storage property on the scan: ColumnStore means the index was used; RowStore means the optimiser picked a B-tree instead.
  • Segments read vs segments skipped. Skipped near zero means elimination is not working — your data is not ordered by the predicate column.
  • Locally aggregated rows on the scan is the fingerprint of aggregate pushdown having fired.
  • In STATISTICS IO, look at lob logical reads. Columnstore traffic shows up there, not in logical reads.
-- the single most useful columnstore query you will run
SELECT i.name                          AS index_name,
       rg.partition_number,
       rg.row_group_id,
       rg.state_desc,                   -- OPEN / CLOSED / COMPRESSED
       rg.total_rows,
       rg.deleted_rows,
       rg.trim_reason_desc,             -- why it closed early
       rg.size_in_bytes / 1048576.0   AS size_mb
FROM sys.dm_db_column_store_row_group_physical_stats AS rg
JOIN sys.indexes AS i
  ON i.object_id = rg.object_id AND i.index_id = rg.index_id
WHERE rg.object_id = OBJECT_ID(N'dbo.Sales')
ORDER BY rg.partition_number, rg.row_group_id;

total_rows near 1,048,576 on most rowgroups is the healthy signal. A long tail of 20K-row rowgroups is the classic sick pattern.

trim_reason_desc names the culprit. On disk-based tables: NO_TRIM, BULKLOAD, REORG, DICTIONARY_SIZE, MEMORY_LIMITATION, RESIDUAL_ROW_GROUP, AUTO_MERGE. SPILLOVER and STATS_MISMATCH only occur on memory-optimized tables.

deleted_rows over 20% of total_rows is Microsoft's own threshold for acting. Also read transition_to_compressed_state_desc — it tells you whether a rowgroup came from BULKLOAD, TUPLE_MOVER, INDEX_BUILD or MERGE.

B-tree versus columnstore, on the dimensions that decide it

CLUSTERED B-TREE (rowstore)CLUSTERED COLUMNSTORE (CCI)
Storage unit8 KB page holding whole rowsColumn segment holding ~1M values of one column, stored as a blob
CompressionROW / PAGE, 8 KB window, ~2–4×Encode → reorder → RLE → bit-pack over 1M values, ~5–10×
Point lookup by keyExcellent — 3–5 logical readsPoor — no seek; add a nonclustered B-tree if you need it
Large aggregate scanReads all columns of all rowsReads only referenced columns of surviving rowgroups
Execution modeRow mode by default; batch mode on rowstore from 2019+/AzureBatch mode, ~900 rows per operator call
Skipping dataIndex seek on the key, partition eliminationRowgroup elimination on min/max, partition elimination, pushdown
Single-row INSERTNative and cheapGoes to the delta B-tree; correct but pays twice
UPDATEIn place where it fitsDelete bit + insert into delta — always grows the index
DELETEPhysical removal from the leafLogical: bitmap bit set, space reclaimed on merge or rebuild
Cost of many query shapesOne covering index per shapeOne index covers every column, so every projection is covered
OrderingPhysically sorted by the clustered keyUnordered unless you build an ordered CCI or load in order
Sweet spotOLTP, seeks, narrow ranges, high-concurrency writesFact tables, reporting, ≥ ~1M rows, scans that aggregate

You do not have to choose. That is what an NCCI is for.

A nonclustered columnstore index sits on top of a normal B-tree table. Writes keep their OLTP shape; the analytic query gets segments, elimination and batch mode. Microsoft calls this real-time operational analytics.

dbo.Orders · clustered B-tree seeks, single-row updates, row locks, the OLTP workload 1001 · all columns 1002 · all columns 1003 · all columns maintainedsynchronously NCCI_Orders a chosen subset of columns, in columnstore format Date Cust Amt delta store + delete buffer absorbs OLTP churn cheaply Analytic queries hit the NCCI in batch mode. Transactional queries never notice it exists.
-- keep the hot rows out of the columnstore entirely
CREATE NONCLUSTERED COLUMNSTORE INDEX NCCI_Orders
    ON dbo.Orders (OrderDate, CustomerId, Region, Amount)
    WHERE Status IN (N'Shipped', N'Closed');

-- and the other direction: a B-tree on a CCI table,
-- supported since SQL Server 2016
CREATE UNIQUE NONCLUSTERED INDEX IX_Sales_OrderId
    ON dbo.Sales (OrderId);
  • A filtered NCCI on cold rows only is the classic HTAP pattern — churn happens on hot rows, which the index never sees, so maintenance cost stays low.
  • COMPRESSION_DELAY is the other HTAP knob: it holds frequently-changing rows in the delta store for N minutes so they stabilise before being compressed, instead of being compressed and then immediately tombstoned.
  • An NCCI is a second copy of its columns, so it costs storage and write amplification like any index. It just compresses far better than a B-tree copy would.
  • Since SQL Server 2016 a CCI table can also carry nonclustered B-tree indexes and a primary key, which buys back seeks.

The failure modes, roughly in order of how often they happen

Rowgroups that never fill

Trickle inserts of a few thousand rows leave a pile of tiny compressed rowgroups. Compression is poor, elimination is useless, and every scan pays per-rowgroup overhead. Fix the load pattern first — batch to 102,400+ or stage and switch partitions.

Memory pressure during the build

Index builds need a large grant. If it is trimmed, rowgroups close early with MEMORY_LIMITATION and you get a permanently worse index. Build with a decent service objective, then scale down.

Wide, high-cardinality strings

A nvarchar(200) free-text column blows the 16 MB dictionary cap, trims rowgroups, and compresses badly. Normalise it out, or exclude it from an NCCI.

Treating it like an OLTP table

Singleton lookups have no seek path. Chatty updates are delete+insert and inflate the index. If the access pattern is WHERE Id = @id, you want a B-tree — or at minimum a nonclustered B-tree on the CCI.

Deleted rows accumulating

Deletes are logical. A rolling-window DELETE job leaves rowgroups that are mostly tombstones but still fully read. Use partition switching for retention instead of DELETE.

Unordered data

A CCI does not sort. If nothing orders your loads by the column you filter on, min/max ranges overlap across every rowgroup and elimination does nothing. This is the most common cause of "columnstore did not help".

Smaller print, and an Azure trap

Persisted computed columns are not supported in a CCI. Non-persisted computed columns are supported on SQL Server 2017+ only — Microsoft's feature matrix lists them as not available on Azure SQL Database or Managed Instance. You also cannot build a nonclustered columnstore index on a computed column at all.

Maintenance playbook, and what Azure does differently

-- merge small + heavily deleted rowgroups, online, cheap
ALTER INDEX CCI_Sales ON dbo.Sales
    REORGANIZE WITH (COMPRESS_ALL_ROW_GROUPS = ON);

-- full rewrite of one partition: best quality, most cost
ALTER INDEX CCI_Sales ON dbo.Sales
    REBUILD PARTITION = 7 WITH (ONLINE = ON, MAXDOP = 1);

-- guarantee elimination: sort at build time (Azure SQL DB,
-- Azure SQL MI on the always-up-to-date policy, SQL Server 2022+)
CREATE CLUSTERED COLUMNSTORE INDEX CCI_Sales ON dbo.Sales
    ORDER (OrderDate)
    WITH (DROP_EXISTING = ON, MAXDOP = 1);
  • REORGANIZE always runs online and costs less than a rebuild — reach for it first. REBUILD is the sledgehammer; scope it to a partition.
  • ONLINE = ON for columnstore create and rebuild arrived in SQL Server 2022, and is available on Azure SQL Database and MI. It is not available on SQL Server 2019 or earlier.
  • MAXDOP = 1 on an ordered build stops parallel streams interleaving and destroying the sort. It is slower, and worth it.
  • Retention by partition switch, never by DELETE.

Where columnstore is available on Azure SQL Database

All vCore tiers. On DTU, Premium and Standard S3 and above; elastic pools need 100 eDTU or more. The reason is memory, not licensing. Scale a database below S3 and the index survives and keeps being maintained, but the optimiser stops choosing it — a quietly nasty way to lose all your performance.

On by default

Azure SQL Database and Managed Instance run the background merge task, so small open delta rowgroups get compressed and heavily deleted rowgroups get merged without you scheduling anything. It is a safety net, not a substitute for loading properly.

Version fine print

Ordered clustered columnstore: SQL Server 2022+, Azure SQL Database, MI. Ordered nonclustered: SQL Server 2025 and Azure SQL Database only. Online create/rebuild of an ordered index: SQL Server 2025 and Azure SQL Database. On Managed Instance all of this tracks the instance update policy.

What to carry away

  • A B-tree optimises finding a row. A columnstore optimises summarising a column. Every other difference follows from that.
  • Three multipliers compound: fewer rowgroups (elimination), fewer columns (projection), fewer bytes per value (compression). Then batch mode removes the per-row CPU tax on whatever survives.
  • The delta store exists because compressed segments are immutable. Everything awkward about columnstore writes traces back to that one fact.
  • 102,400 rows per batch and 1,048,576 rows per rowgroup are the two constants worth memorising.
  • Elimination depends on data order, and a CCI will not sort for you. Load in order, or build an ordered index.
  • It is not either/or: NCCI on an OLTP table, or nonclustered B-trees on a CCI table, get you both shapes.

Reach for columnstore when

The table is a fact table with millions of rows and grows by append. Queries scan and aggregate rather than seek. You keep adding covering indexes to chase reports. Storage or memory is the constraint.

Stay with a B-tree when

Access is by key. Rows are updated individually and often. The table is small enough that a scan is trivial anyway. You need enforced uniqueness on the clustering key as the primary access path.

First diagnostic, every time

Run sys.dm_db_column_store_row_group_physical_stats. Rowgroup sizes, states, deleted counts and trim reasons explain most columnstore performance complaints before you open a single execution plan.

Press o for contents · arrow keys to move · Ctrl/Cmd+P prints every slide.

Sources

Every number, state name, threshold and version claim in this deck was checked against these pages on 2 September 2026. Where two Microsoft pages disagree, the deck says so rather than picking one.

Not from Microsoft documentation

The four-stage encoding pipeline on slide 8 (value encoding, dictionary encoding, row reordering, RLE and bit-packing) is described in the research papers behind the feature, not in Learn: Larson et al., SQL Server Column Store Indexes, SIGMOD 2011, and Enhancements to SQL Server Column Stores, SIGMOD 2013. Learn documents the outcome and the VertiPaq reordering step, not the full algorithm.

Numbers that are illustrative

The byte bars on slide 8 and the 310 GB / 0.8 GB comparison on slide 9 are constructed to show the shape of the three multipliers, not measured from a real workload. Every constant — 1,048,576, 102,400, 900, 16 MB, 20% — is from the pages above.

back / next to move  ·  ← → keys  ·  o for contents  ·  ctrl+P prints all slides

Contents