Part of the DBA-Tools Project.
Heaps in SQL Server Explained
A surprising amount of SQL Server performance pain comes from tables that were never given a clustered index. These heaps often start life as quick prototypes, staging tables, or legacy leftovers — but as data grows, they quietly become one of the most consistent sources of wasted IO inside a production system.
Without a clustered index, SQL Server has no stable physical ordering for the data. Every non‑covering lookup becomes a full table scan. Every update that expands a row creates a forwarded record. Every delete leaves behind space that won’t be reclaimed until you rebuild the structure. Over time, this turns a harmless table into a slow, bloated, IO‑heavy anchor.
This script identifies every heap across all online user databases, highlights the ones with real impact, and gives you the information you need to prioritise fixes.
Why Heaps Matter
A working DBA cares about heaps because they behave differently from “normal” tables (clustered index = ordered structure). The differences show up in IO, CPU, and maintenance overhead:
- Full table scans on non‑covering lookups
- Forwarded records created when updated rows no longer fit in place
- Extra IO from chasing forwarded-record pointers
- Poor delete behaviour — space is not reclaimed until a rebuild
- Unpredictable performance as row counts and page density drift
- Harder troubleshooting because symptoms look like “random IO spikes”
Small, static lookup tables can remain heaps safely. Anything else deserves review.
Common Symptoms
Index fragmentation can contribute to:
- Queries that were fast suddenly become scan‑heavy
- IO spikes during update‑heavy workloads
- Storage growth without matching row growth
- Execution plans showing unexpected table scans
When to Run This Script
- Routine SQL Server health checks
- Before index‑tuning cycles
- After major application releases
- When diagnosing unexplained IO pressure
- When reviewing schema quality in inherited environments
- When storage growth doesn’t match data growth
The Script
Run the following script against your SQL Server instance.
/*
Script Name : Get-Heaps
Category : performance
Purpose : Lists tables with no clustered index (heaps) across all online user databases.
Heaps cause full table scans on every non-covering lookup, accumulate
forwarded records after row updates (degrading IO), and do not reclaim
deleted row space without a REBUILD. Common source of hidden IO pressure
that grows silently as data volumes increase.
Author : Peter Whyte (https://sqldba.blog)
Requires : VIEW DATABASE STATE (iterates each user database)
Notes : Small read-only lookup tables as heaps are usually acceptable.
Prioritise by reserved_mb and forwarded_fetch_count.
forwarded_fetch_count = how often SQL Server chased a forwarded-record pointer
since the last restart (IO cost; forwarded_fetch_count was removed in SS 2025).
has_primary_key = 0 means no PK exists at all — highest priority to fix.
Fix: add a clustered index on the natural key, or add an identity
column and cluster on that if no natural candidate exists.
*/
-- SAFE:ReadOnly
-- IMPACT:Low
SET NOCOUNT ON;
CREATE TABLE #heaps (
database_name sysname NOT NULL,
schema_name sysname NOT NULL,
table_name sysname NOT NULL,
row_count BIGINT NOT NULL,
reserved_mb DECIMAL(10,2) NOT NULL,
forwarded_fetch_count BIGINT NOT NULL,
has_primary_key BIT NOT NULL
);
DECLARE @sql NVARCHAR(MAX) = N'';
SELECT @sql += N'
USE ' + QUOTENAME(name) + N';
INSERT INTO #heaps (database_name, schema_name, table_name, row_count, reserved_mb, forwarded_fetch_count, has_primary_key)
SELECT
DB_NAME(),
s.name,
t.name,
SUM(p.rows),
CAST(SUM(a.total_pages) * 8.0 / 1024 AS DECIMAL(10,2)),
ISNULL(SUM(ios.forwarded_fetch_count), 0),
CASE WHEN EXISTS (
SELECT 1 FROM sys.indexes pk
WHERE pk.object_id = t.object_id AND pk.is_primary_key = 1
) THEN 1 ELSE 0 END
FROM sys.tables t
JOIN sys.schemas s ON t.schema_id = s.schema_id
JOIN sys.indexes i ON t.object_id = i.object_id AND i.type = 0
JOIN sys.partitions p ON i.object_id = p.object_id AND i.index_id = p.index_id
JOIN sys.allocation_units a ON p.partition_id = a.container_id
LEFT JOIN sys.dm_db_index_operational_stats(DB_ID(), NULL, NULL, NULL) ios
ON ios.object_id = i.object_id AND ios.index_id = i.index_id
WHERE t.is_ms_shipped = 0
GROUP BY s.name, t.name, t.object_id
HAVING SUM(p.rows) > 0;
'
FROM sys.databases
WHERE state_desc = 'ONLINE'
AND database_id > 4;
IF LEN(@sql) > 0
EXEC sys.sp_executesql @sql;
SELECT
database_name,
schema_name,
table_name,
row_count,
reserved_mb,
forwarded_fetch_count,
has_primary_key
FROM #heaps
ORDER BY has_primary_key ASC, reserved_mb DESC;
DROP TABLE #heaps;
It queries every online user database, finds tables with no clustered index, and returns row counts, reserved size, forwarded-record activity, and whether a primary key exists.
How To Run From The Repo
Clone DBA Tools, initialize, and run the script:
# Clone dba-tools repo:
git clone https://github.com/peterwhyte-lgtm/dba-tools
# Initialize environment:
cd dba-tools
.\Initialize-Environment.ps1
# Checks for heaps across all online user databases:
.\run.ps1 Get-Heaps
# To run against a remote SQL Server:
.\run.ps1 Get-Heaps -ServerInstance SQLSERVER01
This script lives in the repo at:
sql/performance/indexes/Get-Heaps.sqlpowershell/wrappers/performance/indexes/Get-Heaps.ps1
Example Output
[TODO] Paste real output or screenshot Filename: dba-scripts-get-heaps-output.png
The exact columns may vary slightly by script version, but reserved_mb and forwarded_fetch_count are the primary values to review.
Understanding the Results
Focus on these columns:
- reserved_mb — Larger heaps cause more IO pain. Prioritise the biggest offenders.
- forwarded_fetch_count — How often SQL Server had to chase forwarded-record pointers since last restart. High values = real IO cost.
- has_primary_key — If
0, the table has no primary key at all. These are the highest‑priority fixes. - row_count — Helps validate whether the heap is genuinely large or just noisy.
If your environment uses SQL Server 2025+, note that forwarded_fetch_count was removed — you’ll rely more heavily on reserved size and workload behaviour.
Common Causes (optional)
- Legacy tables created without design review
- ETL staging tables that became permanent
- ORM‑generated schemas
- Rapid prototyping that was never revisited
- “Temporary” tables that grew into production workloads
How to Fix Heaps (optional)
If the table has a natural key:
ALTER TABLE dbo.YourTable
ADD CONSTRAINT PK_YourTable PRIMARY KEY CLUSTERED (YourNaturalKey);
If the table has no natural key:
ALTER TABLE dbo.YourTable
ADD YourIdentity BIGINT IDENTITY(1,1);
ALTER TABLE dbo.YourTable
ADD CONSTRAINT PK_YourTable PRIMARY KEY CLUSTERED (YourIdentity);
Rebuilding the table removes forwarded records and reclaims space.
Best Practices (optional)
- Always define a clustered index unless you have a deliberate reason not to
- Avoid heaps for OLTP workloads
- Rebuild heaps periodically if they must remain heaps
- Review forwarded-record behaviour during update‑heavy operations
- Treat “no primary key” as a schema smell
Related Scripts
You may also find these scripts useful:
- Duplicate Indexes
- Index Design Issues
- Index Fragmentation
- Index Fragmentation Across Databases
- Index Usage Stats
- Missing Indexes
- Unused Indexes
Frequently Asked Questions (optional)
What is a heap? A heap is a table without a clustered index. SQL Server stores its rows without any guaranteed order, which leads to full scans, forwarded records, and poor delete behaviour.
Are heaps always bad? No — tiny, static lookup tables can be heaps safely. Anything large or frequently updated should not be.
Do heaps affect delete performance? Yes. Deleted space is not reclaimed until a rebuild.
Why do forwarded records matter? They force SQL Server to perform extra IO to chase pointers, slowing down lookups and scans.
Summary
Heaps are one of the most common hidden performance problems in SQL Server environments. They often go unnoticed until data volumes grow or workloads shift, at which point they quietly start consuming IO and slowing down queries. Identifying them early — and fixing the ones that matter — is a simple, high‑impact maintenance win.
Add this script to your regular health‑check routine. It’s quick to run, easy to interpret, and helps keep your storage, IO, and schema quality under control.
Leave a Reply