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.
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.
- The row is the atom. To read
Amountyou pull the whole page containing the whole row, including the forty columns you did not ask for. - Compression options are
ROWandPAGE.PAGEgets 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
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.
- 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.
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.
- 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,uniqueidentifieranddatetimeoffsetwith 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 REBUILDorDROP_EXISTING = ON), and elimination works forLIKE 'abc%'but never forLIKE '%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.
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
OrderDatekeeps ~90 of 2,958 rowgroups. - Only
OrderDate,RegionandAmountsegments are fetched — 3 of 40. - Each segment arrives compressed 5–10× and often stays compressed through the aggregate.
SUMandCOUNTare 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.
What actually happens on INSERT, DELETE and UPDATE
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.
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.
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.
- 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 withoutGROUP 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. TreatAVGas 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:
ColumnStoremeans the index was used;RowStoremeans 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 atlob logical reads. Columnstore traffic shows up there, not inlogical 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 unit | 8 KB page holding whole rows | Column segment holding ~1M values of one column, stored as a blob |
| Compression | ROW / PAGE, 8 KB window, ~2–4× | Encode → reorder → RLE → bit-pack over 1M values, ~5–10× |
| Point lookup by key | Excellent — 3–5 logical reads | Poor — no seek; add a nonclustered B-tree if you need it |
| Large aggregate scan | Reads all columns of all rows | Reads only referenced columns of surviving rowgroups |
| Execution mode | Row mode by default; batch mode on rowstore from 2019+/Azure | Batch mode, ~900 rows per operator call |
| Skipping data | Index seek on the key, partition elimination | Rowgroup elimination on min/max, partition elimination, pushdown |
| Single-row INSERT | Native and cheap | Goes to the delta B-tree; correct but pays twice |
| UPDATE | In place where it fits | Delete bit + insert into delta — always grows the index |
| DELETE | Physical removal from the leaf | Logical: bitmap bit set, space reclaimed on merge or rebuild |
| Cost of many query shapes | One covering index per shape | One index covers every column, so every projection is covered |
| Ordering | Physically sorted by the clustered key | Unordered unless you build an ordered CCI or load in order |
| Sweet spot | OLTP, seeks, narrow ranges, high-concurrency writes | Fact 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.
-- 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_DELAYis 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);
REORGANIZEalways runs online and costs less than a rebuild — reach for it first.REBUILDis the sledgehammer; scope it to a partition.ONLINE = ONfor 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 = 1on 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.
- Columnstore indexes: overview 1,048,576 rows per rowgroup · 102,400 minimum bulk-load rowgroup · delta rowgroup is a clustered B-tree · rowgroup states · DELETE and UPDATE semantics
- Columnstore indexes: query performance batches of up to 900 rows · aggregate pushdown conditions · string predicate pushdown and its limits · segment elimination by data type · segment reads / skipped
- What's new in columnstore indexes the feature-by-platform matrix: online rebuild, ordered indexes, computed columns, background merge, and the Azure SQL Database tier footnote
- sys.dm_db_column_store_row_group_physical_stats the five rowgroup states · every trim reason · transition_to_compressed_state · has_vertipaq_optimization · the 20% deleted-rows threshold
- Performance tuning with ordered columnstore indexes segment overlap, MAXDOP during ordered builds
- CREATE COLUMNSTORE INDEX the ORDER clause · supported data types · LOB data over 8 KB stored off-row
- Maximize rowgroup quality for columnstore the 16 MB dictionary cap, one per long-string column · the formula for rowgroup compression memory
- Intelligent query processing details batch mode on rowstore · compatibility level 150 · adaptive joins · memory grant feedback
- Columnstore support in Standard tier Azure SQL Databases S3 and above, elastic pools at 100 eDTU or more, all vCore tiers · and what happens if you scale below S3
- IO statistics for columnstore queries why columnstore work appears as lob logical reads, with logical reads at zero
- Pages and extents architecture guide the 8 KB page, its 96-byte header, and the 64 KB extent
- Columnstore for real-time operational analytics filtered NCCI and COMPRESSION_DELAY for HTAP
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.