Measure Page Splits in SQL Server With T-SQL

Measure page splits with T-SQL by reading the Page Splits/sec counter twice and subtracting the first reading from the second. One reading alone tells you nothing. Two readings tell you how many splits a workload caused.

Gouache painting of a crowded seed tray splitting into two trays with one seedling pot painted red

What a Page Split Is

SQL Server stores index rows on 8 KB pages, in key order. When a row belongs on a full page, SQL Server allocates a new page. It moves about half of the rows onto it. That costs extra reads, writes and log. It also leaves two half-empty pages behind.

Splits are normal. The question is how many a workload causes. Splits in the middle of an index hurt. Splits at the end are only growth.

Read the Counter

The counter lives in sys.dm_os_performance_counters. The object name starts with the instance name, which is why the query matches it with LIKE. The column cntr_type says what kind of number you are reading. Here it is 272696576, a running total since the instance started. The name says per second, but the value is a total. The older view sys.sysperfinfo returns the same number, but it is a compatibility view, so use the newer one.

To measure page splits, first build a test database with two tables. Each row carries a 300 byte note, so a page holds about 24 rows.

IF DB_ID(N'PageSplitDemo') IS NULL CREATE DATABASE PageSplitDemo;
GO
USE PageSplitDemo;
GO
DROP TABLE IF EXISTS dbo.OrdersSequential;
DROP TABLE IF EXISTS dbo.OrdersRandom;
CREATE TABLE dbo.OrdersSequential (
    OrderKey uniqueidentifier NOT NULL DEFAULT NEWSEQUENTIALID() PRIMARY KEY,
    Note     char(300)        NOT NULL DEFAULT 'filler'
);
CREATE TABLE dbo.OrdersRandom (
    OrderKey uniqueidentifier NOT NULL DEFAULT NEWID() PRIMARY KEY,
    Note     char(300)        NOT NULL DEFAULT 'filler'
);

Now read the counter. This needs the VIEW SERVER STATE permission, or VIEW SERVER PERFORMANCE STATE on SQL Server 2022 and later.

SELECT RTRIM(object_name) AS object_name, RTRIM(counter_name) AS counter_name, cntr_value, cntr_type
FROM sys.dm_os_performance_counters
WHERE counter_name = N'Page Splits/sec' AND object_name LIKE N'%Access Methods%';
object_namecounter_namecntr_valuecntr_type
MSSQL$SQLDEV:Access MethodsPage Splits/sec7466141272696576

Your value will differ. Run the demo on a quiet instance, because the counter covers every database since the last restart. Only the difference between two readings is useful.

Measure Page Splits During a Load

Each table gets 5,000 rows, one row per INSERT, so every row finds its own place. The first table uses NEWSEQUENTIALID() keys, which only grow. The second uses NEWID() keys, which land anywhere in the index. The script reads the counter before and after each load.

SET NOCOUNT ON;
DECLARE @t TABLE (LoadType varchar(20), Splits bigint);
DECLARE @before bigint, @i int = 0;
SELECT @before = cntr_value FROM sys.dm_os_performance_counters
WHERE counter_name = N'Page Splits/sec' AND object_name LIKE N'%Access Methods%';
BEGIN TRANSACTION;
WHILE @i < 5000 BEGIN INSERT INTO dbo.OrdersSequential DEFAULT VALUES; SET @i += 1; END;
COMMIT;
INSERT INTO @t SELECT 'Sequential key', cntr_value - @before FROM sys.dm_os_performance_counters
WHERE counter_name = N'Page Splits/sec' AND object_name LIKE N'%Access Methods%';
SET @i = 0;
SELECT @before = cntr_value FROM sys.dm_os_performance_counters
WHERE counter_name = N'Page Splits/sec' AND object_name LIKE N'%Access Methods%';
BEGIN TRANSACTION;
WHILE @i < 5000 BEGIN INSERT INTO dbo.OrdersRandom DEFAULT VALUES; SET @i += 1; END;
COMMIT;
INSERT INTO @t SELECT 'Random key', cntr_value - @before FROM sys.dm_os_performance_counters
WHERE counter_name = N'Page Splits/sec' AND object_name LIKE N'%Access Methods%';
SELECT t.LoadType, t.Splits AS SplitsCounted
FROM @t t;
LoadTypeSplitsCounted
Sequential key211
Random key308

The sequential load still counted 211 splits, yet nothing was split in the middle. The counter also counts each new page added at the end of an index, because the last page was full. That is growth, and it is the baseline for these 5,000 rows. The random load counted about 100 more in the test. Those are the splits that cost something.

Quick card titled Measure Page Splits: Counter: Page Splits/sec in the performance DMV; Value: a running total, so read twice and subtract; Baseline: appends at the end of an index count too; Which index: leaf_allocation_count names it; Fix: a lower fill factor and sequential keys. Tip: Compare with your own baseline, not a fixed limit

The next query shows what they left behind. The random table needs almost half as many pages again, and each page is far emptier.

SELECT OBJECT_NAME(object_id) AS TableName, page_count AS Pages,
       CAST(avg_page_space_used_in_percent AS decimal(5,1)) AS AvgPageFullPct
FROM sys.dm_db_index_physical_stats(DB_ID(), NULL, NULL, NULL, 'DETAILED')
WHERE index_level = 0 AND object_id IN (OBJECT_ID(N'dbo.OrdersSequential'), OBJECT_ID(N'dbo.OrdersRandom'))
ORDER BY TableName DESC;
TableNamePagesAvgPageFullPct
OrdersSequential20996.0
OrdersRandom30565.8

Find the Index Behind the Splits

The instance counter can’t say which index split. sys.dm_db_index_operational_stats can. Its leaf_allocation_count column counts the pages each index has allocated. A page allocated because another was full is a split. In this demo the number equals the page count, because each table has one index.

SELECT OBJECT_NAME(object_id) AS TableName, leaf_allocation_count, nonleaf_allocation_count
FROM sys.dm_db_index_operational_stats(DB_ID(), NULL, NULL, NULL)
WHERE object_id IN (OBJECT_ID(N'dbo.OrdersSequential'), OBJECT_ID(N'dbo.OrdersRandom'));
TableNameleaf_allocation_countnonleaf_allocation_count
OrdersSequential2091
OrdersRandom3051

These counters restart with the instance, so read them as a history since the last restart. To measure page splits for a single statement, use the Extended Events event page_split. It reports each split with its type.

Good and Bad Values

No single number separates healthy from unhealthy. A busy server inserting sequential keys counts thousands of splits and is fine. A quiet server with a few hundred middle-of-index splits an hour can still have slow reports. Compare the rate with a baseline for the same workload, and watch the trend.

Cut the splits at the source. Keys that only grow add pages at the end. A lower fill factor leaves free space on each page. Updates that make a row longer, such as filling in a varchar column later, split pages too. The next script rebuilds the random table at fill factor 100, then at 70. After each rebuild, it counts the splits from 1,000 more random inserts.

SET NOCOUNT ON;
DECLARE @t TABLE (FillFactorUsed int, Splits bigint);
DECLARE @ff int, @before bigint, @i int;
DECLARE @sql nvarchar(200);
DECLARE ff CURSOR LOCAL FAST_FORWARD FOR SELECT v FROM (VALUES (100), (70)) x(v);
OPEN ff; FETCH NEXT FROM ff INTO @ff;
WHILE @@FETCH_STATUS = 0
BEGIN
    SET @sql = N'ALTER INDEX ALL ON dbo.OrdersRandom REBUILD WITH (FILLFACTOR = ' + CAST(@ff AS nvarchar(3)) + N');';
    EXEC (@sql);
    SELECT @before = cntr_value FROM sys.dm_os_performance_counters
    WHERE counter_name = N'Page Splits/sec' AND object_name LIKE N'%Access Methods%';
    SET @i = 0;
    BEGIN TRANSACTION;
    WHILE @i < 1000 BEGIN INSERT INTO dbo.OrdersRandom DEFAULT VALUES; SET @i += 1; END;
    COMMIT;
    INSERT INTO @t SELECT @ff, cntr_value - @before FROM sys.dm_os_performance_counters
    WHERE counter_name = N'Page Splits/sec' AND object_name LIKE N'%Access Methods%';
    FETCH NEXT FROM ff INTO @ff;
END;
CLOSE ff; DEALLOCATE ff;
SELECT FillFactorUsed, Splits AS SplitsForNext1000Rows FROM @t;
FillFactorUsedSplitsForNext1000Rows
100213
7016

At fill factor 100, the 1,000 inserts counted 213 splits. At 70, they counted only 16, because most pages had room. The price is about a third more pages to read. Apply it to the indexes that split, not to every index.

Why Not Use Performance Monitor?

You could argue that Performance Monitor already shows this counter as a rate. It does, and for a quick look it’s fine. T-SQL has one advantage: a scheduled job can write the reading into a table every minute. Then you have a history to compare with, and a sudden jump has a timestamp.

What to Remember

To measure page splits, read the counter twice and subtract. Treat the first reading as meaningless on its own. Record a baseline with sequential inserts, so you know how many splits plain growth causes. Anything above that baseline is worth a look.

When the counter climbs, find the index with leaf_allocation_count before you change anything. Then try a lower fill factor on that index or a key that only grows. When you finish with the demo, drop the test database.

USE master;
GO
ALTER DATABASE PageSplitDemo SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
DROP DATABASE PageSplitDemo;

A page split is not a failure, it is a bill you can read before it grows.

Published by Pinal Dave on SQLAuthority. More of my work at pinaldave.com.


Discover more from SQL Authority with Pinal Dave

Subscribe to get the latest posts sent to your email.

SQL Counter, SQL Index, SQL Performance, SQL Scripts
Previous Post
SQL SERVER – How to Update Two Tables in One Statement?
Next Post
SQL SERVER – Loopback Linked Servers and OPENQUERY with Current Drivers

Related Posts

5 Comments. Leave new

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.