Keyset Paging: Continue After a Date and ID Cursor

Keyset paging continues after a saved ordering key instead of counting past an offset. A date and unique ID together keep equal-date rows in the sequence.

Rich still life of an open blank folio with a red bookmark and four closed slate and sage books.

Choose an ordering key that identifies each row

This example orders posts by CreatedAt, then Id. CreatedAt is nonnull and fixed after insertion. Id is unique and nonnull. Together, they provide an unambiguous ascending position.

Here is the sample table for this post. It has six rows, and the first three share one date. Run this first, in a new query window.

DROP TABLE IF EXISTS #Posts;

CREATE TABLE #Posts
(Id int NOT NULL PRIMARY KEY, CreatedAt datetime2(0) NOT NULL, Title varchar(20) NOT NULL);

INSERT #Posts VALUES
(1,'2025-01-01T09:00:00','A'),(2,'2025-01-01T09:00:00','B'),
(3,'2025-01-01T09:00:00','C'),(4,'2025-01-02T09:00:00','D'),
(5,'2025-01-03T09:00:00','E'),(6,'2025-01-03T09:00:00','F');

The cursor is the last returned date and ID. It is a pair of application values, rather than a SQL Server CURSOR object. The client must preserve both values without changing their precision.

Fetch the first page

The first request has no previous position. Return the first rows using the declared order. The page size must be a validated positive integer. This example uses two rows per page to make tied dates easy to inspect.

DECLARE @PageSize int=2;
SELECT TOP(@PageSize) Id,CreatedAt,Title
FROM #Posts
ORDER BY CreatedAt,Id;

Continue after both saved values

Take the date and ID from the final row in that page. A later date qualifies regardless of its ID. An equal date qualifies only when its ID is greater. Keep the same ordering in every request.

DECLARE @AfterDate datetime2(0)='2025-01-01T09:00:00';
DECLARE @AfterId int=2;
SELECT TOP(2) Id,CreatedAt,Title FROM #Posts
WHERE CreatedAt>@AfterDate
 OR(CreatedAt=@AfterDate AND Id>@AfterId)
ORDER BY CreatedAt,Id;

Filtering only on a greater date drops remaining rows tied with the cursor date. In this example, IDs 1, 2 and 3 share that date. The first page ends at ID 2. The next page still needs ID 3. Here is the date-only version, and ID 3 never shows up.

DECLARE @AfterDate datetime2(0)='2025-01-01T09:00:00';
SELECT TOP(2) Id,CreatedAt,Title FROM #Posts
WHERE CreatedAt>@AfterDate -- date only: ID 3 is missing
ORDER BY CreatedAt,Id;
Page forward from a saved position

The saved row can disappear

The continuation predicate uses saved scalar values, so the cursor row need not still exist. Here I delete ID 2 after saving its position. The next request still starts after that date and ID. Looking up the deleted row would unnecessarily lose the position.

DELETE FROM #Posts WHERE Id=2;

DECLARE @AfterDate datetime2(0)='2025-01-01T09:00:00';
DECLARE @AfterId int=2;
SELECT TOP(2) Id,CreatedAt,Title FROM #Posts
WHERE CreatedAt>@AfterDate
 OR(CreatedAt=@AfterDate AND Id>@AfterId)
ORDER BY CreatedAt,Id;

Keep the ordering fields immutable for this contract. Updating an ordering field can move a row across the saved boundary. That can cause omissions or repeated rows across requests. An ID tie-breaker resolves equal dates; it does not freeze changing data.

Changing data needs its own policy

A newly inserted row before the cursor is outside the next request. A newly inserted row after it can appear in that request. This example makes these changes one after another. It does not establish a snapshot spanning separate requests or test concurrent sessions.

INSERT #Posts VALUES
(7,'2024-12-31T09:00:00','Inserted before'),
(8,'2025-01-02T08:00:00','Inserted after');

DECLARE @AfterDate datetime2(0)='2025-01-01T09:00:00';
DECLARE @AfterId int=2;
SELECT TOP(2) Id,CreatedAt,Title FROM #Posts
WHERE CreatedAt>@AfterDate
 OR(CreatedAt=@AfterDate AND Id>@AfterId)
ORDER BY CreatedAt,Id;

I would choose this approach for a forward feed or continuation workflow. It does not provide an exact total or a direct jump to page 500. Those are separate requirements. A cursor can also become stale when the filter or sort contract changes.

Inspect the index without promising a plan

The sample index starts with CreatedAt and Id, then includes Title. It supports the declared ordering and covers the returned fields. Its maintenance and storage costs still matter in a real workload. Query syntax alone cannot establish a particular physical operator or read count.

CREATE INDEX IX_Posts_Cursor
 ON #Posts(CreatedAt,Id) INCLUDE(Title);

This article does not replace an exact count with an estimate. It makes the continuation boundary explicit. Plans, IO and concurrency claims require their own measured evidence.

With these six sample rows and a page size of 2, the first page ends at ID 2 and the next page returns IDs 3 and 4. Date-only continuation omits ID 3, while the saved pair still works after ID 2 is deleted. The staged insertion test returns IDs 3 and 8, without establishing a cross-request snapshot.

SSMS grids: keyset pages A,B and C,D, a date-only page that loses C, a page after a cursor-row delete, and one after inserts.
From top to bottom: the first page (A, B), the second page after the cursor (C, D), a date-only cursor that loses row C (D, E), the page after the cursor row is deleted, and the page after rows are inserted before and after the cursor. This captured result does not establish a cross-request snapshot. View the native result at full size.

When you finish, drop the sample table.

DROP TABLE IF EXISTS #Posts;

Save the date and the ID, and the next page starts exactly where the last one ended.

Keyset paging is not a numbered-page shortcut, it is a continuation contract built on a complete ordering key.

Published by Pinal Dave on SQLAuthority. More of my work at pinaldave.com.


Discover more from SQL Authority with Pinal Dave

Subscribe to get the latest posts sent to your email.

SQL Coding Standards, SQL Index, SQL Performance, SQL Server
Previous Post
SQL SERVER – SELECT * FROM dual – Dual Equivalent
Next Post
Stop Blaming the User: Let Constraints Catch Bad Data

Related Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.