Index Bloat in Postgres: Why It Matters, How to Identify, How to Resolve
Index bloat in Postgres can cause problems, but it’s easy to miss.
I’ve written about how vacuum problems can prevent PostgreSQL from …
Read MoreI see HEAP tables are found even when I know those tables have a clustered index, and I see a lot of forwarded records. This happens to 5 tables in my database. I can see the clustered and in some ones the non-clustered indexes… why are some scripts reporting them as heaps?
Here is an example of what I’m seeing from a script:
dbo.AnonymousTable (0) [HEAP] [RID] / 2,126,697 reads, 308,401 writes / 17,847 forwarded records fetched;
This puzzles a lot of people when they start working with indexes in SQL Server. The concepts here overlap and there are quite a few different ways you can do things.
Watch the 27 minute video discussing this, or scroll on down to read a written version of the video, complete with code samples.
Subscribe to the podcast, if you’d like to listen on the go! And a review on iTunes will help others find out about the show.
First up, let’s clarify the concepts.
I say “disk-based” because we’re not talking about in-Memory tables here. I’m not getting into those today for the sake of simplicity. (For a high level overview of disk-based vs in-Memory tables, check out this post.)
Clustered Index: This will always have IndexID = 1
Clustered rowstore tables - Traditional clustered index: you choose clustering key column(s) that determine the sort order of the data
Clustered columnstore tables - Clustered columnstore indexes don’t have key columns. Every column in the table is stored in columnar format, which uses LOB (large-object) pages
The syntax to create a clustered index may look like one of these samples:
/* These samples create clustered indexes that are NOT also a primary key */
/* 1. Two step process... */
CREATE TABLE dbo.ClusterMeToo (
MakeMeAClusteredIndex BIGINT IDENTITY NOT NULL,
Col2 NVARCHAR(100)
);
GO
CREATE UNIQUE CLUSTERED INDEX CX_ClusterMeToo_MakeMeAClusteredIndex
ON dbo.ClusterMeToo (MakeMeAClusteredIndex);
GO
/* 2. Inline index create. This syntax works in SQL Server 2014+ */
CREATE TABLE dbo.ClusterMe (
MakeMeAClusteredIndex BIGINT IDENTITY NOT NULL,
Col2 NVARCHAR(100),
INDEX CX_ClusterMe_MakeMeAClusteredIndex UNIQUE CLUSTERED (MakeMeAClusteredIndex)
);
GO
/* 3. Clustered Columnstore Example. This exists in SQL Server 2014+ */
CREATE TABLE dbo.ClusteredColumnstore (
Col1 BIGINT IDENTITY NOT NULL,
Col2 NVARCHAR(100),
INDEX CX_ClusteredColumnstore_MakeMeAClusteredIndex CLUSTERED COLUMNSTORE
);
GONotes:
Heap: This will always have IndexID = 0
The syntax to create a heap is something like this:
CREATE TABLE dbo.HeapExample (
IMightBeAPK BIGINT IDENTITY NOT NULL,
Col2 NVARCHAR(100)
);
GO
/* It remains a heap if you do NOT run any commands like this:
CREATE [UNIQUE] CLUSTERED INDEX [index name] on [table name] ( [Column Name(s) ] or
ALTER TABLE [table name] ADD CONSTRAINT [constraint name] PRIMARY KEY CLUSTERED ( [Column Name(s)] )
CREATE CLUSTERED COLUMNSTORE INDEX [index name] on [table name]
*/A table may have only one primary key (PK). (You can enforce uniqueness in other ways with unique constraints and unique indexes, though.)
A primary key is secretly an index! It can be clustered or nonclustered.
Your primary key may technically be a “surrogate key”. That just means that it’s not a column that “naturally” identifies the data– it may be an INT, BIGINT, or UNIQUEIDENTIFIER column that was designed to uniquely identify the row, even though the number or uniqueidentifier itself isn’t meaningful to look at.
Clustered primary key: This will always have IndexID = 1 (it’s a clustered index behind the scenes, as well as a constraint)
The syntax to create a clustered primary key can look like this:
CREATE TABLE dbo.ClusteredPKExample (
MakeMeACXPK BIGINT IDENTITY NOT NULL,
Col2 NVARCHAR(100),
CONSTRAINT PK_ClusteredPKExample_MakeMeACXPK
PRIMARY KEY CLUSTERED (MakeMeACXPK)
);
GO
/* Or a two step create... */
CREATE TABLE dbo.AnotherClusteredPKExample (
MakeMeACXPK BIGINT IDENTITY NOT NULL,
Col2 NVARCHAR(100)
);
GO
ALTER TABLE dbo.AnotherClusteredPKExample
ADD CONSTRAINT PK_AnotherClusteredPKExample_MakeMeACXPK
PRIMARY KEY CLUSTERED (MakeMeACXPK);
GONonclustered primary key: This will always have IndexID > 1
The syntax to create a nonclustered primary key may look something like this:
/* 1. This syntax works in SQL Server 2014+ */
CREATE TABLE dbo.NonclusteredPKExample (
MakeMeCX BIGINT IDENTITY NOT NULL,
BusinessKey NVARCHAR(50) NOT NULL,
Col2 NVARCHAR(100),
INDEX CX_NonclusteredPKExample_MakeMeCX UNIQUE CLUSTERED (MakeMeCX),
CONSTRAINT PK_NonclusteredPKExample_BusinessKey
PRIMARY KEY NONCLUSTERED (BusinessKey)
);
GO
/* 2. Three step create... */
CREATE TABLE dbo.AnotherNonclusteredPKExample (
MakeMeCX BIGINT IDENTITY NOT NULL,
BusinessKey NVARCHAR(50) NOT NULL,
Col2 NVARCHAR(100)
);
GO
ALTER TABLE dbo.AnotherNonclusteredPKExample
ADD CONSTRAINT PK_AnotherNonclusteredPKExample_BusinessKey
PRIMARY KEY NONCLUSTERED (BusinessKey);
GO
CREATE UNIQUE CLUSTERED INDEX cx_AnotherNonclusteredPKExample_MakeMeCX
on dbo.AnotherNonclusteredPKExample (MakeMeCX);
GO
/* 3. Clustered Columnstore with PK, two step version.
This works in SQL Server 2016+ */
CREATE TABLE dbo.ClusteredColumnstoreWithPKExample (
BusinessKey NVARCHAR(50) NOT NULL,
Col1 BIGINT IDENTITY NOT NULL,
Col2 NVARCHAR(100),
CONSTRAINT PK_ClusteredColumnstoreWithPKExample_BusinessKey
PRIMARY KEY NONCLUSTERED (BusinessKey)
);
GO
CREATE CLUSTERED COLUMNSTORE INDEX cCx_ClusteredColumnstoreWithPKExample
on dbo.ClusteredColumnstoreWithPKExample;
GOBased on the initial email, I was pretty sure that the tables in question were accidentally created as heaps, each with a nonclustered primary key.
The big giveaways were that indexid zero only ever exists on a heap, and forwarded records can also only occur in a heap object. In a brief email conversation back and forth, we confirmed that this was the case.
I’ve run into this quite a few times in the wild. Sometimes the tables were a heap and someone later thought to add primary keys, and made them nonclustered without thinking. Sometimes people just accidentally use the wrong syntax at create time.
In the case of our questioner, they likely want to just recreate their nonclustered primary keys and clustered primary keys. That’s kind of a pain if you’ve got a lot of foreign keys or SQL Server replication set up.
While it’s generally a bad practice to have a unique clustered index and a non-clustered primary key on the same columns, because they’re duplicate indexes… if the tables are small and don’t have a lot of modifications, I’m not going to pin a scarlet letter on you for doing it a few times.
But for general use, let’s make some generalizations!
Clustered primary key: When the set of columns that uniquely identify a row are also very frequently used in joins and the ‘where’ clause of your query, ordering the table by those columns is usually a great fit. The clustered index automagically has direct access to all the in-row columns in a table without having to look it up in another structure.
Unique clustered index with a different nonclustered primary key (rowstore): Sometimes you have a table where it makes sense to physically sort the table on different columns than the ones that make up the primary key on the table:
Heap tables (possibly with a nonclustered PK, depending what you’re doing): You don’t always need a clustered index. Or any index for that matter. Heaps can have some weird problems, like those forwarded records, but that’s for another day. Heaps can be good for:
Columnstore indexes (clustered and nonclustered): These are extremely powerful when you need to scan a lot of rows to do aggregations.
Is my excitement for columnstore indexes in SQL Server 2016 showing?

Don’t be jealous, you can get one, too.
Sitting right next to me as I write this is the brand spanking new fifth edition of Louis Davidson’s Pro SQL Server Relational Database Design and Implementation, updated for SQL Server 2016. You can buy the book now from APress or Amazon.
Those are not affiliate links and this is not a sponsored post. I’m just excited to read the fifth edition, and if you got this far in this post then you’d probably like it, too.
If you’re in the process of modeling a SQL Server database, get Louis’ book. It will help you along the way, and in designing future projects as well.
Copyright (c) 2025, Catalyze SQL, LLC; all rights reserved. Opinions expressed on this site are solely those of Kendra Little of Catalyze SQL, LLC. Content policy: Short excerpts of blog posts (3 sentences) may be republished, but longer excerpts and artwork cannot be shared without explicit permission.