Writes, optimizes, and debugs T-SQL queries. Explains SQL Server internals, troubleshoots performance issues, and guides database administration tasks including backup/restore, high availability, security, and index design. Use when the user asks about T-SQL syntax, SQL Server administration, query performance, stored procedures, indexes, locking, transactions, backup/restore, high availability, security, or any MSSQL-related topic — even without saying 'SQL Server' explicitly. Also trigger on terms like SSMS, tempdb, bcp, sqlcmd, MSSQL, sp_executesql, NOLOCK, columnstore, Hekaton, RCSI, param sniffing, or execution plan.
100
100%
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
Passed
No known issues
Columnstore indexes are the right choice when:
Avoid columnstore when:
Traditional rowstore stores all columns for a row together (row-major order). Columnstore stores each column separately (column-major order), compressed per column segment.
Rowstore (B-tree leaf page):
[col1, col2, col3, col4] row 1
[col1, col2, col3, col4] row 2
...
Columnstore rowgroup:
segment for col1: [val1, val1, val1, val2, ...] ← high compression (similar values)
segment for col2: [val2, val2, val3, val3, ...]
...Key structural units:
| Unit | Description |
|---|---|
| Row group | Group of ~1,048,576 rows compressed together (max row group size) |
| Column segment | Compressed data for one column within one row group |
| Delta store | B-tree rowstore that receives new inserts until full (< 1,048,576 rows) |
| Delete bitmap | Marks logically deleted rows; actual removal happens at tuple mover or rebuild |
| Tuple mover | Background process that compresses closed delta stores into compressed row groups |
[!NOTE] SQL Server 2016
sys.dm_db_column_store_row_group_physical_statsDMV added — prefer this over the oldersys.column_store_row_groupsfor physical details.
A clustered columnstore index (CCI) is the primary storage structure for the table. The table has no separate heap or B-tree — the CCI is the table.
-- Create a table with CCI as primary storage
CREATE TABLE dbo.FactSales
(
SaleID INT NOT NULL,
ProductID INT NOT NULL,
CustomerID INT NOT NULL,
SaleDate DATE NOT NULL,
Quantity INT NOT NULL,
UnitPrice DECIMAL(10,2) NOT NULL,
TotalAmount DECIMAL(12,2) NOT NULL
)
WITH (DATA_COMPRESSION = COLUMNSTORE); -- explicit; CCI implies columnstore compression
CREATE CLUSTERED COLUMNSTORE INDEX CCI_FactSales
ON dbo.FactSales;
-- Or combined:
CREATE TABLE dbo.FactSales2
(
SaleID INT NOT NULL,
SaleDate DATE NOT NULL,
Amount DECIMAL(12,2) NOT NULL,
INDEX CCI_FactSales2 CLUSTERED COLUMNSTORE
);Convert an existing heap or clustered B-tree to CCI:
-- Drop existing clustered index and replace with CCI
-- (This rebuilds the entire table — schedule appropriately)
CREATE CLUSTERED COLUMNSTORE INDEX CCI_MyTable
ON dbo.MyTable
WITH (DROP_EXISTING = ON, ONLINE = ON); -- ONLINE requires Enterprise[!NOTE] SQL Server 2022
ONLINE = ONfor columnstore operations is more robust. Resumable columnstore index operations added.
You can add nonclustered B-tree indexes to a CCI table for point-lookup performance:
-- CCI table with an added B-tree index for point lookups
CREATE CLUSTERED COLUMNSTORE INDEX CCI_FactSales ON dbo.FactSales;
CREATE NONCLUSTERED INDEX IX_FactSales_SaleID
ON dbo.FactSales (SaleID); -- for single-row lookups by primary keyThe optimizer chooses between the CCI (for range/aggregate queries) and the B-tree (for point lookups).
A nonclustered columnstore index (NCCI) sits alongside the existing rowstore (heap or clustered B-tree). The base table structure is unchanged.
-- Add a read-only analytics index to an OLTP table
CREATE NONCLUSTERED COLUMNSTORE INDEX NCCI_Orders_Analytics
ON dbo.Orders (OrderDate, CustomerID, ProductID, Quantity, TotalAmount)
WHERE OrderDate >= '2020-01-01'; -- optional filtered NCCIAn NCCI on an updateable table is read-only by default until SQL Server 2016. From SQL Server 2016+, NCCI supports DML on the base table (inserts/updates/deletes automatically maintain the NCCI).
[!NOTE] SQL Server 2016 Updateable NCCI on disk-based tables. Before 2016, DML on a table with an NCCI was blocked unless the NCCI was disabled.
Include only columns the analytics queries actually access. Narrower NCCIs compress better and use less memory during scans:
-- Good: analytics columns only
CREATE NONCLUSTERED COLUMNSTORE INDEX NCCI_Orders
ON dbo.Orders (OrderDate, RegionID, ProductID, Revenue, Cost);
-- Wasteful: includes all 40 columns — hurts compression, increases memory
CREATE NONCLUSTERED COLUMNSTORE INDEX NCCI_Orders_Wide
ON dbo.Orders; -- all columns = heap-like, defeats the purposeINSERT (< 1,048,576 rows threshold)
│
▼
┌─────────────┐
│ Delta store │ (open B-tree rowstore, not yet compressed)
│ (OPEN state)│
└──────┬──────┘
│ delta store fills to ~1M rows or REORGANIZE/REBUILD triggered
▼
┌─────────────┐
│ Delta store │
│ (CLOSED state)│ ← ready for compression but still B-tree
└──────┬──────┘
│ tuple mover background thread (runs ~5-min intervals)
▼
┌──────────────────────┐
│ Compressed row group │ ← column segments, dictionary, bitmaps
│ (COMPRESSED state) │
└──────────────────────┘A CLOSED delta store is still in rowstore format — queries must scan it as rowstore. This can hurt performance during heavy insert workloads before the tuple mover catches up.
-- REORGANIZE compresses all CLOSED delta stores immediately (no full rebuild)
ALTER INDEX CCI_FactSales ON dbo.FactSales REORGANIZE
WITH (COMPRESS_ALL_ROW_GROUPS = ON);[!NOTE] SQL Server 2016
COMPRESS_ALL_ROW_GROUPS = ONoption added toALTER INDEX ... REORGANIZE.
SELECT
OBJECT_NAME(i.object_id) AS table_name,
i.name AS index_name,
rg.state_description,
rg.total_rows,
rg.deleted_rows,
rg.size_in_bytes
FROM sys.dm_db_column_store_row_group_physical_stats rg
JOIN sys.indexes i ON rg.object_id = i.object_id AND rg.index_id = i.index_id
WHERE OBJECT_NAME(i.object_id) = 'FactSales'
ORDER BY rg.row_group_id;State values:
| State | Meaning |
|---|---|
OPEN | Delta store accepting inserts |
CLOSED | Delta store full, awaiting tuple mover |
COMPRESSED | Compressed column segments |
TOMBSTONE | Being removed after rebuild |
PRE_COMPRESSED | In-flight transition (rare) |
A full row group has ~1,048,576 rows. Partially full row groups compress worse and require more rowgroups to be scanned:
-- Detect suboptimal (trim) row groups
SELECT
OBJECT_NAME(object_id) AS table_name,
index_id,
state_description,
total_rows,
deleted_rows,
(total_rows - deleted_rows) AS live_rows,
size_in_bytes / 1024.0 / 1024.0 AS size_mb,
CASE
WHEN state_description = 'COMPRESSED' AND total_rows < 900000
THEN 'SUBOPTIMAL - consider REBUILD'
ELSE 'OK'
END AS assessment
FROM sys.dm_db_column_store_row_group_physical_stats
WHERE OBJECT_NAME(object_id) = 'FactSales'
ORDER BY row_group_id;Common causes of small row groups:
COMPRESS_ALL_ROW_GROUPS on tables with low insert volumeBatch sizes ≥ 102,400 rows bypass the delta store and go directly to compressed row groups (bulk import path):
-- Minimum 102,400 rows per batch for direct-path compression
BULK INSERT dbo.FactSales
FROM 'C:\data\sales.csv'
WITH (
BATCHSIZE = 1048576, -- 1M rows → full row groups
TABLOCK, -- required for minimal logging + direct path
DATAFILETYPE = 'char',
FIELDTERMINATOR = ',',
ROWTERMINATOR = '\n'
);
-- Or via INSERT ... SELECT with TABLOCK
INSERT INTO dbo.FactSales WITH (TABLOCK)
SELECT * FROM dbo.FactSales_Staging; -- must be > 102,400 rows per insert[!NOTE]
TABLOCKis required for direct-path (bulk) loading into columnstore. Without it, inserts go through delta stores regardless of batch size.
Segment elimination is the columnstore equivalent of index seek — the engine skips entire column segments (and their row groups) based on min/max metadata stored per segment.
Compressed row group #1: OrderDate segment min=2020-01-01, max=2020-03-31
Compressed row group #2: OrderDate segment min=2020-04-01, max=2020-06-30
Compressed row group #3: OrderDate segment min=2020-07-01, max=2020-09-30
Query: WHERE OrderDate BETWEEN '2020-04-15' AND '2020-06-15'
→ Only row group #2 is scanned. Row groups #1 and #3 are eliminated.Segment elimination works for: =, >, >=, <, <=, BETWEEN, IN (constant list), and IS NULL.
Load data in sorted order on the most frequently filtered column:
-- Load in date order to maximize segment elimination on OrderDate
INSERT INTO dbo.FactSales WITH (TABLOCK)
SELECT *
FROM dbo.FactSales_Staging
ORDER BY OrderDate; -- sort before insert
-- Or use REORGANIZE after load to re-sort within row groups (partial help)
ALTER INDEX CCI_FactSales ON dbo.FactSales REORGANIZE
WITH (COMPRESS_ALL_ROW_GROUPS = ON);[!NOTE] SQL Server 2022 Columnstore ordered CCI (
ORDERclause onCREATE CLUSTERED COLUMNSTORE INDEX) is available, similar to Azure Synapse. This preserves sort order across row groups for maximal segment elimination.1
-- 2022+ ordered CCI (if available in your build)
CREATE CLUSTERED COLUMNSTORE INDEX CCI_FactSales
ON dbo.FactSales
ORDER (SaleDate);Look for Columnstore Index Scan with Segments Read < total Segments in the actual execution plan (requires SSMS or Extended Events to see segment-level stats):
-- DMV: segment statistics per object
SELECT
OBJECT_NAME(s.object_id) AS table_name,
s.column_id,
c.name AS column_name,
s.segment_id,
s.min_data_id,
s.max_data_id,
s.row_count,
s.on_disk_size
FROM sys.column_store_segments s
JOIN sys.columns c ON c.object_id = s.object_id AND c.column_id = s.column_id
WHERE OBJECT_NAME(s.object_id) = 'FactSales'
ORDER BY s.column_id, s.segment_id;Traditional rowstore execution processes one row at a time through each operator (row mode). Columnstore enables batch mode, which processes 64–900 rows per CPU vector operation.
| Mode | Rows per operation | CPU usage | Best for |
|---|---|---|---|
| Row mode | 1 | Higher | OLTP point lookups |
| Batch mode | 64–900 | Lower (SIMD) | Aggregations, large scans |
Batch mode operators (when eligible): Hash Join, Sort, Aggregate, Filter, Compute Scalar, Window Aggregate.
An operator switches to batch mode when:
[!NOTE] SQL Server 2019 Batch mode on rowstore (IQP feature): batch mode can activate for rowstore tables even without a columnstore index, when the optimizer estimates it's beneficial (compat level 150+).2
In SSMS, hover over any operator in the actual execution plan. Look for:
Actual Execution Mode: Batch (good for analytics)Actual Execution Mode: Row (fallback, investigate why)Common reasons batch mode doesn't activate:
07-functions.md)-- Typical star-schema query — designed to leverage columnstore
SELECT
d.CalendarYear,
p.ProductCategory,
r.RegionName,
SUM(f.Quantity) AS TotalUnits,
SUM(f.TotalAmount) AS TotalRevenue,
AVG(f.UnitPrice) AS AvgPrice
FROM dbo.FactSales f
JOIN dbo.DimDate d ON f.SaleDateKey = d.DateKey
JOIN dbo.DimProduct p ON f.ProductID = p.ProductID
JOIN dbo.DimRegion r ON f.RegionID = r.RegionID
WHERE d.CalendarYear BETWEEN 2021 AND 2023
AND p.ProductCategory = 'Electronics'
GROUP BY d.CalendarYear, p.ProductCategory, r.RegionName
ORDER BY d.CalendarYear, TotalRevenue DESC;Best practices for columnstore-friendly analytics queries:
SELECT * — only project needed columns (column pruning reduces I/O)-- Window aggregate — can use batch mode in 2019+
SELECT
SaleDate,
TotalAmount,
SUM(TotalAmount) OVER (
ORDER BY SaleDate
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS RunningTotal
FROM dbo.FactSales
WHERE SaleDate >= '2023-01-01';[!NOTE] SQL Server 2019 Window aggregate operator in batch mode (
ROWS BETWEENframing) added in 2019 for compat level 150+.
-- NCCI on OLTP table for real-time analytics
-- Base table: clustered B-tree on OrderID (OLTP access pattern)
-- NCCI: analytics columns only
CREATE NONCLUSTERED COLUMNSTORE INDEX NCCI_Orders_Analytics
ON dbo.Orders (OrderDate, CustomerID, ProductID, Quantity, TotalAmount, Status);
-- OLTP query — uses clustered B-tree
SELECT OrderID, CustomerID, Status
FROM dbo.Orders
WHERE OrderID = 12345678;
-- Analytics query — optimizer chooses NCCI
SELECT
YEAR(OrderDate) AS OrderYear,
MONTH(OrderDate) AS OrderMonth,
SUM(TotalAmount) AS Revenue
FROM dbo.Orders
WHERE Status = 'Completed'
GROUP BY YEAR(OrderDate), MONTH(OrderDate);This means heavy UPDATE/DELETE workloads accumulate deleted rows in compressed segments, wasting space and requiring more rows scanned:
-- Check delete ratio
SELECT
OBJECT_NAME(object_id) AS table_name,
index_id,
state_description,
total_rows,
deleted_rows,
CAST(deleted_rows * 100.0 / NULLIF(total_rows, 0) AS DECIMAL(5,1)) AS pct_deleted
FROM sys.dm_db_column_store_row_group_physical_stats
WHERE OBJECT_NAME(object_id) = 'FactSales'
AND state_description = 'COMPRESSED'
ORDER BY pct_deleted DESC;Threshold: Consider rebuilding when average delete ratio exceeds ~20%.
The sliding window pattern for columnstore fact tables:
-- Step 1: Prepare staging table with same structure + CCI
CREATE TABLE dbo.FactSales_Staging
(
SaleID INT NOT NULL,
SaleDate DATE NOT NULL,
Amount DECIMAL(12,2) NOT NULL,
INDEX CCI_Staging CLUSTERED COLUMNSTORE
);
-- Step 2: Load and compress staging data (full row groups)
INSERT INTO dbo.FactSales_Staging WITH (TABLOCK)
SELECT * FROM dbo.FactSales_Raw_Load
ORDER BY SaleDate; -- sort for segment elimination
ALTER INDEX CCI_Staging ON dbo.FactSales_Staging
REORGANIZE WITH (COMPRESS_ALL_ROW_GROUPS = ON);
-- Step 3: Switch partition into main table
ALTER TABLE dbo.FactSales_Staging
SWITCH TO dbo.FactSales PARTITION @NewPartitionNumber;See references/10-partitioning.md for full partition switching setup.
| Operation | Effect | Locking | Notes |
|---|---|---|---|
REBUILD | Recreates all row groups, removes all deleted rows, re-sorts data | Table lock (row-lock with ONLINE=ON) | Full defragmentation; expensive |
REORGANIZE | Compresses CLOSED delta stores; merges small rowgroups | Row-level, minimal blocking | Faster, doesn't remove deleted rows from compressed segments |
REORGANIZE WITH (COMPRESS_ALL_ROW_GROUPS = ON) | Forces OPEN delta stores to close and compress | Same as REORGANIZE | Use after bulk loads |
-- Light maintenance: compress delta stores after incremental load
ALTER INDEX CCI_FactSales ON dbo.FactSales REORGANIZE
WITH (COMPRESS_ALL_ROW_GROUPS = ON);
-- Heavy maintenance: full rebuild (monthly or when >20% deleted rows)
ALTER INDEX CCI_FactSales ON dbo.FactSales REBUILD
WITH (ONLINE = ON, MAXDOP = 4); -- ONLINE requires Enterprise[!NOTE] SQL Server 2017 Resumable index rebuild (
RESUMABLE = ON) available for clustered indexes. Columnstore resumable rebuild support is limited — check your specific build.
CCI automatically maintains column statistics. The REBUILD operation also updates statistics. REORGANIZE does not update statistics.
After a REORGANIZE-only maintenance window:
-- Manually update statistics after REORGANIZE
UPDATE STATISTICS dbo.FactSales WITH FULLSCAN;Columnstore compression is separate from ROW/PAGE compression and generally achieves higher compression ratios:
-- Estimate columnstore compression savings
EXEC sp_estimate_data_compression_savings
@schema_name = 'dbo',
@object_name = 'FactSales',
@index_id = NULL,
@partition_number = NULL,
@data_compression = 'COLUMNSTORE';
-- Also check COLUMNSTORE_ARCHIVE for rarely accessed cold data
EXEC sp_estimate_data_compression_savings
@schema_name = 'dbo',
@object_name = 'FactSales',
@index_id = NULL,
@partition_number = NULL,
@data_compression = 'COLUMNSTORE_ARCHIVE';COLUMNSTORE_ARCHIVE applies additional CPU-intensive compression (LZ + Xpress) for cold partitions at the cost of higher scan CPU:
-- Apply archive compression to old partitions
ALTER INDEX CCI_FactSales ON dbo.FactSales REBUILD
PARTITION = 1 -- old/cold partition
WITH (DATA_COMPRESSION = COLUMNSTORE_ARCHIVE);The optimizer may choose rowstore operators even with a CCI on small tables. Below ~100K rows, the overhead of delta stores and row group scanning can exceed rowstore B-tree performance. Columnstore shines at millions of rows.
-- Bad: CCI table, single-row lookup — full row group must be decompressed
SELECT * FROM dbo.FactSales WHERE SaleID = 12345678;
-- Better: Add a nonclustered B-tree index for point lookup
CREATE NONCLUSTERED INDEX IX_FactSales_SaleID ON dbo.FactSales (SaleID);
-- Optimizer will use the B-tree index for this queryHeavy insert workloads create many OPEN/CLOSED delta stores. Queries must scan delta stores as rowstore (row mode), potentially negating batch mode benefits. Monitor with sys.dm_db_column_store_row_group_physical_stats and run REORGANIZE after major loads.
Columnstore does not support columns of type VARCHAR(MAX), NVARCHAR(MAX), VARBINARY(MAX), XML, TEXT, NTEXT, IMAGE, ROWVERSION, SQL_VARIANT, CLR types, or FILESTREAM. If your table has these, you cannot create a CCI; use NCCI with only the non-LOB analytics columns.
-- This will FAIL if dbo.Orders has a Notes VARCHAR(MAX) column
CREATE CLUSTERED COLUMNSTORE INDEX CCI_Orders ON dbo.Orders;
-- Error: Column 'Notes' has a data type that cannot participate in a columnstore index.
-- Workaround: NCCI on non-LOB columns only
CREATE NONCLUSTERED COLUMNSTORE INDEX NCCI_Orders
ON dbo.Orders (OrderDate, CustomerID, Amount, Quantity);
-- Omit Notes columnA scalar UDF anywhere in the query (SELECT list, WHERE, JOIN) forces the entire query tree into row mode:
-- Bad: scalar UDF in SELECT list forces row mode
SELECT dbo.FormatCurrency(TotalAmount), SaleDate -- row mode, slow
FROM dbo.FactSales;
-- Good: inline the logic or use an iTVF
SELECT FORMAT(TotalAmount, 'C', 'en-US'), SaleDate -- batch mode eligible
FROM dbo.FactSales;Exception: scalar UDF inlining (SQL Server 2019+) may resolve this — check sys.sql_modules.is_inlineable. See references/07-functions.md.
Columnstore operations (especially sort + bulk load into row groups) require large memory grants. Under memory pressure, these spill to tempdb and degrade to row mode. Monitor with:
SELECT
session_id,
granted_memory_kb,
used_memory_kb,
ideal_memory_kb,
is_small,
query_cost
FROM sys.dm_exec_query_memory_grants
WHERE session_id = @@SPID;WITH (NOLOCK) on a CCI table does not reduce locking overhead (CCI uses row versioning, not lock-based isolation for scans). The hint is ignored for CCI scans but can cause issues with delta stores. Prefer READ_COMMITTED_SNAPSHOT isolation at the database level.
-- Segment elimination works for direct predicates
WHERE SaleDate >= '2023-01-01' -- ✓ segment elimination
-- Segment elimination does NOT help with negation
WHERE SaleID NOT IN (SELECT SaleID FROM dbo.Returns) -- ✗ full scanFor partitioned CCI tables, use STATISTICS_INCREMENTAL = ON to update statistics per partition rather than full-table scans:
-- Enable incremental stats on CCI fact table
CREATE STATISTICS ST_FactSales_SaleDate
ON dbo.FactSales (SaleDate)
WITH INCREMENTAL = ON;
-- Update only the latest partition
UPDATE STATISTICS dbo.FactSales ST_FactSales_SaleDate
WITH RESAMPLE ON PARTITIONS (24); -- only partition 24[!NOTE] SQL Server 2016 Nonclustered columnstore indexes on memory-optimized (Hekaton) tables are supported from SQL Server 2016+. See
references/18-in-memory-oltp.mdfor memory-optimized table constraints.
references/08-indexes.md — B-tree index internals, fill factor, fragmentationreferences/10-partitioning.md — Partition switching with columnstore, sliding window patternreferences/18-in-memory-oltp.md — Columnstore on memory-optimized tablesreferences/28-statistics.md — Statistics maintenance, STATISTICS_INCREMENTALreferences/29-query-plans.md — Reading batch mode vs row mode in execution plansreferences/31-intelligent-query-processing.md — Batch mode on rowstore (2019+), memory grant feedbackreferences/36-data-compression.md — COLUMNSTORE_ARCHIVE compression, sp_estimate_data_compression_savingsColumnstore indexes: Overview - SQL Server — architecture concepts, rowgroup/delta store lifecycle, ordered CCI availability by platform ↩
Intelligent Query Processing Details - SQL Server — batch mode on rowstore (compat level 150+), IQP feature details ↩
references