LyraLearn AI Learning Platform
Exams
← Module 6 Β· Reporting Workloads and a Telerik Triage Playbook

Paging: OFFSET/FETCH vs Keyset

Any report with a grid needs paging. The two standard approaches have very different performance characteristics at depth, and the difference matters as soon as the result set is large.

OFFSET/FETCH

The straightforward option, available since SQL Server 2012:

SELECT  o.OrderId, o.OrderDate, o.CustomerId, o.Total
FROM    dbo.Orders AS o                       -- placeholder table
WHERE   o.OrderDate >= @From AND o.OrderDate < @To
ORDER BY o.OrderDate DESC, o.OrderId DESC
OFFSET  @PageNumber * @PageSize ROWS
FETCH NEXT @PageSize ROWS ONLY;

Clean, readable, supports jumping to an arbitrary page number, and it is what most grid components generate.

The cost is in how it works: to return rows 100,001 through 100,050, SQL Server produces the first 100,000 rows and discards them. Cost grows linearly with page depth. Page 1 is instant; page 2,000 is not.

For most reports this is acceptable, because users rarely go past the first few pages. It becomes a real problem in two situations: a very large result set with genuine deep paging, and an export loop that walks every page β€” which turns an O(n) export into O(nΒ²).

The ORDER BY must be deterministic

If the sort columns are not unique, rows can appear on two pages or on none, because the engine is free to order tied rows differently between executions. Always append a unique tiebreaker:

ORDER BY o.OrderDate DESC, o.OrderId DESC   -- OrderId makes the ordering total

This is a correctness issue, not a performance one, and it produces bug reports that are very hard to reproduce.

The index that makes paging cheap

Paging needs the data already in the sorted order, or the engine sorts the whole result set before it can skip anything.

CREATE NONCLUSTERED INDEX IX_Orders_OrderDate_OrderId
    ON dbo.Orders (OrderDate DESC, OrderId DESC)
    INCLUDE (CustomerId, Total);

With this in place the plan is an ordered index scan with a Top operator β€” no Sort. Without it, every page request sorts the entire filtered set. Check the actual plan for a Sort operator: if one is there, the index does not match the ORDER BY.

Keyset pagination

Instead of "skip 100,000 rows," say "give me the rows after this one." The client remembers the last row it saw and passes those values back.

-- First page
SELECT TOP (@PageSize)
        o.OrderId, o.OrderDate, o.CustomerId, o.Total
FROM    dbo.Orders AS o
WHERE   o.OrderDate >= @From AND o.OrderDate < @To
ORDER BY o.OrderDate DESC, o.OrderId DESC;

-- Subsequent pages: @LastDate and @LastId come from the last row of the previous page
SELECT TOP (@PageSize)
        o.OrderId, o.OrderDate, o.CustomerId, o.Total
FROM    dbo.Orders AS o
WHERE   o.OrderDate >= @From AND o.OrderDate < @To
  AND ( o.OrderDate < @LastDate
     OR (o.OrderDate = @LastDate AND o.OrderId < @LastId) )   -- the tiebreaker
ORDER BY o.OrderDate DESC, o.OrderId DESC;

Every page seeks directly to its starting position. Page 2,000 costs the same as page 2. Cost is constant with depth.

The compound comparison expresses "strictly after (LastDate, LastId) in the sort order." The row_constructor form (WHERE (OrderDate, OrderId) < (@LastDate, @LastId)) is not supported in T-SQL, so the explicit OR version above is the way to write it.

What you give up: you cannot jump to page 47. Only next and previous. That rules it out for a grid with numbered page links, and makes it ideal for infinite scroll, "load more," and export loops.

Comparing them

| | OFFSET/FETCH | Keyset | |---|---|---| | Cost at page N | grows with N | constant | | Jump to arbitrary page | yes | no | | Total page count | easy (with a count query) | not naturally available | | Rows shifting during paging | can duplicate or skip rows | stable | | Grid component support | universal | needs custom wiring | | Best for | typical grids, shallow paging | deep paging, exports, infinite scroll |

That "rows shifting" row is worth a moment. If data is inserted while a user pages, OFFSET-based paging shifts everything down and the user sees a row twice or misses one. Keyset paging is anchored to a value, so it is stable under concurrent inserts.

Measuring the difference

SET STATISTICS IO, TIME ON;

DECLARE @PageSize int = 50;

-- Deep OFFSET: reads and discards everything before the page
SELECT TOP (@PageSize) object_id, name
FROM (
    SELECT object_id, name,
           ROW_NUMBER() OVER (ORDER BY object_id) AS rn
    FROM sys.all_columns
) AS x
WHERE rn > 5000
ORDER BY rn;

-- Keyset equivalent: seeks straight to the position
DECLARE @LastId int = (SELECT MAX(object_id) FROM (
        SELECT TOP (5000) object_id FROM sys.all_columns ORDER BY object_id) AS y);

SELECT TOP (@PageSize) object_id, name
FROM   sys.all_columns
WHERE  object_id > @LastId
ORDER BY object_id;

SET STATISTICS IO, TIME OFF;

The gap widens as the offset grows. Try it at 5,000 and then at 50,000.

The row-count query

Grids want a total count for the pager. Do not run the full query twice.

-- Count and page in one pass, when the result set is manageable
SELECT  o.OrderId, o.OrderDate, o.Total,
        COUNT(*) OVER () AS TotalRows
FROM    dbo.Orders AS o
WHERE   o.OrderDate >= @From AND o.OrderDate < @To
ORDER BY o.OrderDate DESC, o.OrderId DESC
OFFSET @PageNumber * @PageSize ROWS FETCH NEXT @PageSize ROWS ONLY;

COUNT(*) OVER () gives the total in the same pass. Note that it does require the engine to enumerate the full filtered set, so on a very large result set a separate cached count, or an approximate count, is better. A common compromise: run the exact count once when the filter changes, cache it, and page against it.

The short version

OFFSET/FETCH is fine for shallow paging but it reads and discards every row before the offset, so cost grows with page depth β€” and an export loop that walks all pages turns into O(nΒ²). Keyset paging uses the last row's key values in the WHERE clause, so every page seeks directly and costs the same. The trade is that you lose arbitrary page jumps. Either way the ORDER BY needs a unique tiebreaker or rows can appear on two pages.

🧠 Quiz yourself on this lesson →

Ask the AI Tutor

Grounded in the course lessons β€” it cites its sources and says when it doesn't know.