Row-Overflow and LOB: When a Row Leaves the Page

A woman archivist at a filing desk with an overstuffed folder whose excess pages spill into a separate overflow drawer via a labeled pointer, and a larger bundle stored in a distant vault linked by a thin pointer line, illustrating how SQL Server moves oversized row data off-page into row-overflow and LOB allocation units.

A data page is 8 KB, and a single row is capped at 8,060 bytes of in-row data.[1] So what happens when you declare two varchar(5000) columns, or store a 20,000-character varchar(max), in a row that plainly cannot hold it? SQL Server does not refuse the row; it moves part of it off the page and leaves a pointer behind. This post creates both situations, watches the extra allocation units appear, and reads the small in-row footprint that is left when the data lives elsewhere.

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

Three places a row can live

Every table stores its rows in one to three kinds of allocation unit, and the names tell you exactly what each holds:[1]

  • IN_ROW_DATA holds the row itself, the normal data page, and every table has one.
  • ROW_OVERFLOW_DATA holds variable-length columns (varchar, nvarchar, varbinary) that were pushed off the page because the row grew past 8,060 bytes.
  • LOB_DATA holds large object columns (varchar(max), nvarchar(max), varbinary(max), xml, and the deprecated text/image).

A table only has the second or third unit if it actually needs it. Let us make it need them.

Pushing a row past 8060 bytes

Two varchar(5000) columns. Individually each fits; together, filled, they are 10,000 bytes, well over the limit:

The insert succeeds. Asking which allocation units the table now owns shows why:

A ROW_OVERFLOW_DATA allocation unit appeared. When the row exceeded 8,060 bytes, SQL Server moved the widest variable-length column, one of the 5,000-byte strings, out to an overflow page, and left a 24-byte pointer in the row that references it.[1] The other string stayed in row. This movement is dynamic: shorten the row again with an update and SQL Server can pull the column back in-row.

The trade-off is I/O. A query that touches the overflowed column now follows the pointer to a second page, so a scan that would have read one page reads two. It is the price of allowing rows that do not fit.

Large objects live out of row by design

varchar(max) is different. It is a LOB type, and once its value is large it goes to a LOB_DATA unit as a matter of course, not only when the row overflows:

The allocation units:

The 20,000 X characters are in the LOB_DATA unit. What is left on the data page is tiny. Here is the in-row record:

A 20,000-byte value, and the record on the data page is 63 bytes. The column reads [BLOB Inline Root]: an in-row structure that points out to the tree of LOB pages holding the actual bytes. For a small LOB the documentation describes a 16-byte pointer to the separate page tree; the inline root you see here is a slightly larger structure carrying the links, but the principle is the same. The bulk of the data is not on this page.

What this means in practice

  • Wide rows are allowed but not free. Declaring varchar(8000) columns that can jointly exceed 8,060 bytes works, but rows that overflow pay an extra page read whenever the off-row column is touched. Keep frequently-read columns narrow enough to stay in row.
  • Off-row storage is dynamic for overflow, structural for LOB. A varchar(n) moves off row only when the row is too big, and can move back. A varchar(max) value is a LOB and lives in LOB_DATA by design once it is large.
  • SELECT * is worse than it looks on these tables. Every off-row column that the query does not actually need is still an extra page fetch when it is read. Selecting only the columns you need can turn a two-or-three-page read back into one.
  • Consider large value types out of row. The large value types out of row table option, and for LOBs the in-row threshold, control how aggressively data goes off page. The default keeps small values in row for speed; force everything off row only when the in-row bloat is hurting scans of the narrow columns.

The 8,060-byte limit is not a wall your data hits; it is the point at which SQL Server quietly starts keeping part of the row somewhere else and remembering where. Once you can see the allocation units and the pointer, a “why is this scan reading so many pages” question often answers itself.

Have you tracked a surprising page count back to a varchar(max) or an overflowed row? I would like to hear it. Find me on Bluesky or LinkedIn.

References

  1. Page and Extent Architecture Guide – Microsoft Learn. The 8,060-byte row limit, the ROW_OVERFLOW_DATA 24-byte in-row pointer for variable-length columns, and the 16-byte LOB pointer to the text/LOB page tree.