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.

SQL Server OFFSET FETCH vs keyset pagination

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 BY is 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 FETCHKeyset
Jump to page NYesNo (next/previous only)
Deep page performanceGets slowerStays constant
Rows inserted while pagingCan shift or duplicate rowsStable
ComplexityVery simpleNeeds 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 than COUNT(*) OVER() on every row of every page.

Quick checklist

  • Always end ORDER BY with 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

Popular posts from this blog

How to Read a SQL Server Execution Plan (Beginner's Guide)

SARGable Queries in SQL Server: Why Functions in WHERE Slow You Down

SQL Server Statistics Explained: Why Your Query Plans Go Wrong