SQL Server Pagination: OFFSET FETCH vs Keyset Paging
Almost every application needs paging: order lists, search results, audit logs. SQL Server makes it easy with OFFSET ... FETCH, and that works fine for the first few pages. On large tables, though, page 5,000 can be hundreds of times slower than page 1. Keyset paging fixes that.

OFFSET FETCH
DECLARE @PageNumber INT = 3, @PageSize INT = 50;
SELECT OrderId, OrderDate, CustomerId, Total
FROM dbo.Orders
ORDER BY OrderDate DESC, OrderId DESC
OFFSET (@PageNumber - 1) * @PageSize ROWS
FETCH NEXT @PageSize ROWS ONLY;ORDER BYis required.- Always include a unique column (such as the primary key) at the end of
ORDER BY. Without it, rows with the same date can appear on two pages or on none.
Why deep pages get slow
To return page 1,000 with 50 rows per page, SQL Server must read the first 49,950 rows in order and throw them away, then return the next 50. The deeper the page, the more work, even with a perfect index. On a busy table, deep pages also take locks or read versions for all those skipped rows.
Keyset (seek) pagination
Instead of saying "skip N rows", keyset paging says "give me the rows after the last one I saw". The application remembers the sort values of the last row on the current page and passes them back:
DECLARE @LastDate DATETIME2 = '2026-10-20 14:32:00',
@LastId INT = 98231,
@PageSize INT = 50;
SELECT TOP (@PageSize) OrderId, OrderDate, CustomerId, Total
FROM dbo.Orders
WHERE OrderDate < @LastDate
OR (OrderDate = @LastDate AND OrderId < @LastId)
ORDER BY OrderDate DESC, OrderId DESC;With an index that matches the sort order, SQL Server seeks directly to the right spot and reads only 50 rows, whether it is the 2nd page or the 2,000th.
CREATE INDEX IX_Orders_OrderDate_OrderId
ON dbo.Orders (OrderDate DESC, OrderId DESC)
INCLUDE (CustomerId, Total);For the first page, simply run the query without the WHERE clause.
Trade-offs
| OFFSET FETCH | Keyset | |
|---|---|---|
| Jump to page N | Yes | No (next/previous only) |
| Deep page performance | Gets slower | Stays constant |
| Rows inserted while paging | Can shift or duplicate rows | Stable |
| Complexity | Very simple | Needs last-row values from the client |
Which to use
- OFFSET FETCH: admin screens and small tables, or when users really need "go to page 7".
- Keyset: infinite scroll, "Load more" buttons, APIs that export or sync data, and any large table where users or jobs read deep pages.
Don't count every row on every page
Showing "Page 3 of 12,840" requires counting all matching rows, which can cost more than fetching the page itself. Options:
- Count once and cache the result, or show an approximate count.
- Show only "Next" and "Previous" without a total.
- If you must, use a separate
COUNT(*)query with the same filter rather thanCOUNT(*) OVER()on every row of every page.
Quick checklist
- Always end
ORDER BYwith a unique column. - Index the sort columns in the same order and direction.
- Use keyset paging for large tables and deep pages.
- Test performance on a deep page, not just page 1.
Related reading:
Comments
Post a Comment