Forwarded Records: What an UPDATE Can Do to a Heap

A woman courier at a wall of mail slots where one slot holds a small forwarding note with an arrow pointing to the actual parcel now sitting in a different slot, illustrating how a SQL Server heap leaves a forwarding pointer when an updated row must move.

A heap, a table with no clustered index, has a failure mode that clustered tables do not: an UPDATE that makes a row larger can force it to physically move, and SQL Server leaves a pointer behind at the old location so nonclustered indexes and the RID can still find it. These are forwarded records, and they are one of the better reasons to think twice before leaving a busy table as a heap. This post creates one on purpose, finds the forwarding stub and the forwarded record on the pages, and explains why they punish scans.

This continues the byte-level storage series alongside the NULL bitmap, row-overflow and LOB, and where each column lives in a record. If DBCC PAGE is new to you, this older post covers the mechanics. Every byte below is from a SQL Server 2019 instance, and the script is included so you can reproduce it.

Why a heap forwards a row

In a heap, a row is identified by its physical location: an 8-byte row identifier (RID) of file, page, and slot.[1] Nonclustered indexes point at rows by that RID. That creates a problem when a row grows. If you update a variable-length column so the row no longer fits where it sits, the row has to move to a page with room, but moving it would change its RID and invalidate every index pointer aimed at it.

SQL Server’s solution is to leave a forwarding pointer in the original slot that points to the row’s new home, the forwarded record. The RID never changes; anyone who arrives at the old location is redirected. The documentation is candid about the cost: following these pointers during a scan “limits read-ahead performance, and can incur additional I/O which reduces scan performance.”[1]

Making one happen

Ten medium rows that together fill a single 8 KB page, then an update that grows the middle one past the space left on that page:

sys.dm_db_index_physical_stats counts the damage before and after the update:

The forwarded record count went from 0 to 1, and the record count rose to 11 even though there are still only 10 rows. That extra “record” is the forwarding stub, which the engine counts alongside the real rows.

The stub and the forwarded record

Dumping the pages shows the two halves. On the original page, in the slot that used to hold row 5, there is now a stub:

Nine bytes. The first, 04, is the status byte marking this as a forwarding stub. The remaining eight are the RID of the new location, and they decode cleanly: page 0x00002408 = 9224, file 0x0003 = 3, slot 0x0001 = 1. In other words, “the row you are looking for now lives at 3:9224, slot 1.”

Go to that page and there it is, flagged as a forwarded record:

The id is 05 00 00 00 = 5, followed by the 3,000 X characters (58 is X). This is the real row, relocated. A forwarded record also carries a back-pointer to its stub, so the engine can find its way home if the row shrinks and moves back.

Why it hurts, and what to do

The performance cost is about scan efficiency. When SQL Server scans a heap in allocation order and hits a forwarding stub, it has to jump to the forwarded record’s page to read the row, then return to continue the scan. That extra hop defeats read-ahead and, on a heap riddled with forwarded records, can multiply the I/O of a full scan.

  • Forwarded records are a heap-only problem. A clustered table identifies rows by key, not physical location, so a growing row causes a page split (its own cost) but never a forwarding pointer. This is one concrete reason most busy tables want a clustered index.
  • Updates that grow variable-length columns are the trigger. A workload that inserts short values and later fills them in, or that updates varchar columns to longer strings, is the classic way a heap accumulates forwarded records.
  • You can measure and fix it. sys.dm_db_index_physical_stats reports forwarded_record_count for heaps. To clear them, rebuild the heap with ALTER TABLE ... REBUILD, which rewrites the rows in place and removes the forwarding pointers. A clustered index removes the possibility entirely.
  • Heaps still have good uses. Staging tables that are bulk-loaded and truncated, never updated in place, will not forward. The trouble comes specifically from in-place growth.

Forwarded records are the heap quietly paying interest on a decision to skip a clustered index. Seeing the stub, RID and all, makes the abstract “heaps fragment under updates” warning concrete: there really is a little “moved, see 3:9224” note sitting where your row used to be.

Have you found a heap full of forwarded records behind a slow scan? I would like to hear it. Find me on Bluesky or LinkedIn.

References

  1. Heaps (Tables without clustered indexes) – Microsoft Learn. The 8-byte RID (file, page, slot), how an update that needs more space creates a forwarded record and forwarding pointer, and the effect on read-ahead and scan performance.