Forwarded Records: the Back-Pointer and the Trip Home

Illustration of a row of navy and steel-blue mailboxes; one holds a forwarding card connected by a glowing gold thread to a parcel at a distant mailbox, while a letter carrier follows the thread back carrying a smaller parcel

Note: a self-contained MCVE reproducing every result in this post is included at the end – see "The MCVE: run it yourself".

Hugo Kornelis recently challenged a sentence in my post on how SQL Server stores forwarded records. The sentence claimed that a forwarded record carries a back-pointer to its stub, and that the engine uses it to move the row home if it shrinks. It was a fair challenge: I’d taken both claims on faith from sources I trust, but I hadn’t verified either one myself.

Paul Randal wrote about the back-pointer back in 2009, and in a 2016 comment on that post he noted that while a forwarded row can move back, it’s “devilishly difficult” to get all the conditions to line up for a demo.[1] This post shows the back-pointer in raw bytes, catches a row moving home, and works out the conditions that decide whether the trip happens at all.

Tests ran on SQL Server 2019 (CU32) and SQL Server 2025 (RTM). Every result below is identical on both versions.

Setting the stage

First, a recap of the mechanism. When an UPDATE grows a heap row past what its page can hold, SQL Server moves the row to another page and leaves a 9-byte forwarding stub at the old address. Nonclustered indexes keep pointing at the stub, so none of them need to be touched. The row’s RID never changes; the stub redirects any process that looks for the row in its original location.[2]

Here’s a small heap to play with:

Ten rows of 750 characters each. Nine land on the first data page, which I’ll call page 344 since that’s what my test database assigned. Now grow row 5 so it can’t stay put:

sys.dm_db_index_physical_stats confirms one forwarded record. %%physloc%% still reports row 5 at its original address, slot 4 on page 344. That’s the stub’s location, not the row’s. The RID is the row’s permanent public address, and stability is the entire point of the forwarding mechanism. If you want to know where the bytes actually live, you have to go find them.

The back-pointer, in bytes

DBCC PAGE on page 344 shows slot 4 has become a FORWARDING_STUB, nine bytes, “Forwarding to = file 1 page 345 slot 1”. That much matches the documentation.

The forwarded record on page 345 is 3,027 bytes: 3,000 characters of data plus 15 bytes of ordinary record overhead plus 12 bytes that weren’t there before. DBCC PAGE prints a decoded “Forwarded from = file 1 page 344 slot 4” line for the record (it didn’t when Paul wrote about the back-pointer in 2009; he’d rewritten DBCC PAGE for SQL Server 2005 and admits he forgot to make it decode the back-pointer). It still doesn’t tell you which bytes hold that pointer, so let’s find them in the memory dump.

The last ten bytes of the record dump are:

Reading the little-endian pieces:

  • 0004 = 1024, a marker value Paul describes as the special column ID signifying a back-pointer. It’s not a real column ID. Tables top out at 1,024 columns (30,000 if you use column sets over sparse columns, which live in a different structure inside the record). Since the storage engine numbers column IDs from zero, 1024 is the 1,025th value: the first one an ordinary in-row column can’t occupy, which makes it a safe sentinel.[3]
  • 5801 0000 = page 344.
  • 0100 = file 1.
  • 0400 = slot 4.

File 1, page 344, slot 4: the stub’s location. The remaining 2 bytes of the 12-byte growth are the extra entry in the variable-length column offset array that the back-pointer occupies.

Paul proved the same thing in 2009 with size arithmetic: create a nonunique clustered index on the heap and the forwarded record shrinks by exactly 10 bytes (the back-pointer goes away, and a 2-byte uniquifier offset entry appears, netting -10 against ordinary rows which grow by 2). I reproduced that too: 3,027 bytes as a heap forwarded record, 3,017 under the clustered index. Two independent methods, same answer.

What the back-pointer is actually for

The back-pointer’s primary purpose isn’t the return trip; it’s keeping the stub current when the row moves again.

Grow row 5 again while it’s forwarded, enough that it can’t stay on page 345 either:

The record moves to a third page. Does page 345 now hold a second stub, creating a chain? No. The original stub on page 344 is updated in place and now reads “Forwarding to = file 1 page 346 slot 0”. Page 345 is left clean. The engine could only do that because the forwarded record knew where its stub was. Without the back-pointer, every re-move would either leave a trail of hops or force a scan to find the stub.

Catching the row moving home

Now for the claim Hugo questioned. Shrink row 5 back down while the home page still has room:

Result: the stub is gone. Slot 4 on page 344 is a normal PRIMARY_RECORD again, the forwarded record count drops to zero, and the row’s RID is unchanged. The row went home on the first try. No index rebuild, no ALTER TABLE REBUILD, just an ordinary UPDATE.

So in the simple case, move-back isn’t difficult at all. Which raises the question: why did Paul call it devilishly difficult? Because the simple case has three hidden conditions, and production heaps rarely satisfy all of them.

Condition 1: the row returns only when an UPDATE touches it

The engine never returns a forwarded row lazily. I filled the home page, shrank the forwarded row (it stayed forwarded, correctly, since there was no room), then freed the home page’s space again. The row stayed forwarded. Nothing happens until the next UPDATE touches the forwarded row – and then any UPDATE counts. A no-op (SET v = v) brought the row home in my tests. The check runs whenever the row is updated; the update doesn’t have to change anything.

There’s no background task that cleans up forwarding the way ghost cleanup removes deleted records. If your workload never updates a forwarded row again, the forwarding persists until you rebuild the heap (ALTER TABLE ... REBUILD), create a clustered index, or delete the row.

Condition 2: the row must fit – and “fit” is layout-dependent

This one took some work to pin down.

I built a state where the home page reported 1,949 free bytes, then shrank the forwarded row to 750 characters (a 765-byte record). A 765-byte record against 1,949 free bytes should fit. It didn’t move. Shrinking to 400 characters worked. Somewhere in between was a threshold, and it didn’t line up with the page’s total free space or with any percentage-based rule.

The explanation is in the page’s physical layout. Free space on a page comes in two flavors: the contiguous chunk between the end of the last row and the slot array, and the fragments (holes) scattered where deleted or shrunk rows used to be. The returning record needs enough gatherable space, and how much the engine can gather depends on where the holes sit. Two useful reference points from the boundary testing, each reproducible to the byte:

  • The floor is the contiguous chunk plus 9 bytes (the stub the return deletes – its space counts toward the budget). On one heavily churned page the boundary sat at exactly contiguous + 9: 649 + 9 = 658 bytes, with a 665-byte record stuck and a 658-byte record returning.
  • The engine can do better than the floor: it reorganizes the page to a limited extent. On a page with 649 contiguous bytes and two 500-byte holes, the boundary landed at 1,158 bytes = contiguous + one hole + 9. On a page with an extra stub and three holes, it landed at 1,658 = contiguous + two holes + 9.

Which holes the engine can reclaim varies with the layout; I haven’t found a closed-form rule that predicts every construction, and I’d be interested to hear from anyone who has. What I can say: for a given page layout the boundary is deterministic, exact, and identical on SQL Server 2019 and 2025.

Contrast this with the grow-in-place case: when a row grows, SQL Server compacts the page to keep the row where it is whenever the total free space allows. The return trip gets the limited version of that courtesy: some reorganization, but not full compaction.

One tooling footnote from these tests: on the builds I tested (2019 CU32 and 2025 RTM), the sys.dm_db_page_info column holding the free-space offset is named free_bytes_offset, though the documentation calls it free_data_offset. Query the function’s result set with sys.dm_exec_describe_first_result_set if a documented column comes up Msg 207.

Condition 3: the home page still has to have that room

Obvious, but worth stating because of why it tends to fail. Heap inserts are placed using PFS pages, which track each page’s fullness in coarse buckets: empty, up to 50%, up to 80%, up to 95%, and up to 100% full.[4] A page that forwarded a row away has free space, so it advertises itself as a landing spot for new inserts. In an active heap, that hole gets refilled quickly, and the space is gone before any update gives the row a chance to return.

The PFS buckets govern where inserts land. The move-back decision itself doesn’t use them; it evaluates the actual byte layout of the home page. Coarse filter for placement, exact check for the return.

Putting it together

The original sentence in my post holds up: the back-pointer exists, and the engine really does use the forwarding machinery to move a shrinking row home. But the fuller picture is worth the extra paragraph:

  • The back-pointer’s primary purpose is keeping the one-and-only stub current when the row moves again. No chains, ever.
  • Move-back happens only when an UPDATE touches the forwarded row – and any update counts, including one that changes nothing. There’s no background cleanup.
  • The returning record must fit a layout-dependent budget on the home page: at minimum the contiguous free chunk plus the reclaimed 9-byte stub, and often one or more free-space holes on top – the engine reorganizes the page to a limited extent, identically on SQL Server 2019 and 2025.
  • Refilled pages and never-updated rows are why forwarded records accumulate in real heaps, and why ALTER TABLE ... REBUILD remains the practical cure.

My thanks to Hugo for pushing on that sentence. Verifying a claim you’ve repeated in public is a useful kind of discomfort; this one turned out to hold far more than one sentence’s worth.

Questions? Something I should test next? Find me on Bluesky or LinkedIn, or leave a comment below.

The MCVE: run it yourself

Everything above is reproducible with the self-contained script below. It needs SQL Server 2019 or later, sysadmin (for DBCC PAGE), and nothing else: it creates a scratch database named fwd_mcve, computes every page number dynamically, finds the fit boundary on your build with a binary search, and leaves cleanup commented out at the end. Expected results are labeled in each step’s output.

Expected output

Page numbers will differ on your instance; everything else should match. This is the output from SQL Server 2025 (RTM) on Windows; SQL Server 2019 (CU32) and SQL Server 2025 on Linux (via db<>fiddle) produce the same numbers. You can also run the whole script on dbfiddle.uk.

Related posts

References

  1. Forwarding and forwarded records, and the back-pointer size – Paul Randal, SQLskills, 2009. Establishes the 10-byte back-pointer (a 2-byte marker plus the 8-byte RID) and the size-delta technique for measuring it. The 2016 comment thread is where the move-back is described as devilishly difficult to demonstrate.
  2. 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 why the RID stays stable through the move.
  3. Maximum capacity specifications for SQL Server – Microsoft Learn. 1,024 columns per table, and 30,000 for a table using a column set over sparse columns, which is what puts the value 1024 outside the ordinary in-row column range.
  4. Pages and extents architecture guide – Microsoft Learn. Page and slot-array layout, and the PFS free-space buckets (empty, 50%, 80%, 95%, 100%) that decide which page a heap insert lands on.