EF Core ↔ SQL Server: OFFSET/FETCH vs Keyset Pagination for Large Tables
0 reputation · 02 Mar 2021, 11:01 UTC
0 reputation · 02 Mar 2021, 11:01 UTC
When retrieving pages from a table that contains hundreds of thousands of rows, EF Core’s Skip/Take operators are the most straightforward option. They are translated to SQL OFFSET/FETCH clauses when an OrderBy is present, giving a simple page‑based API. However, each OFFSET forces the database to read and discard all preceding rows, which can become a bottleneck as page numbers grow.
The design space is constrained by several factors. A deterministic OrderBy is mandatory; otherwise the generated SQL can return non‑stable page results. The database provider influences performance: PostgreSQL handles OFFSET/FETCH efficiently, while SQLite does not. Keyset (seek) pagination requires a unique, monotonic ordering column and a WHERE clause on the last seen key, but EF Core does not expose a built‑in API for it.
Because EF Core lacks native keyset support, developers must decide whether to accept the potential performance hit of OFFSET/FETCH or to implement custom keyset logic. The choice also depends on data volatility, index design, and whether page stability is critical. I need guidance on the trade‑offs and practical patterns for integrating keyset pagination into an EF Core workflow.
Which pagination technique—OFFSET/FETCH or keyset—should I favor for a high‑volume, frequently updated table when using EF Core 7 on PostgreSQL?
What patterns can I adopt in EF Core to support keyset pagination without resorting to raw SQL or third‑party libraries?
35275 reputation · 02 Mar 2021, 13:43 UTC
For a high‑volume, frequently updated table on PostgreSQL with EF Core 7, favor keyset (seek) pagination. It delivers roughly constant‑time page reads and stable results under concurrent writes, whereas OFFSET/FETCH cost grows linearly with page depth and can skip or duplicate rows when data shifts.
query.Where(x => x.Id > lastId).OrderBy(x => x.Id).Take(pageSize) is pure LINQ and EF Core 7 translates it to the optimal seek plan.var page = await ctx.Entities
.Where(e => e.Id > lastSeenId)
.OrderBy(e => e.Id)
.Take(pageSize)
.ToListAsync();
Generates: WHERE "Id" > @lastSeenId ORDER BY "Id" LIMIT @pageSize.
EF Core 7 cannot translate ValueTuple comparisons directly, so expand the predicate:
var page = await ctx.Entities
.Where(e => e.CreatedAt > lastCreatedAt
|| (e.CreatedAt == lastCreatedAt && e.Id > lastSeenId))
.OrderBy(e => e.CreatedAt)
.ThenBy(e => e.Id)
.Take(pageSize)
.ToListAsync();
Ensure the ORDER BY columns match the predicate order.
Create a composite index that matches the ordering columns exactly:
CREATE INDEX IX_Entities_CreatedAt_Id ON "Entities" ("CreatedAt", "Id");
Without this index the keyset query degrades to a sort/scan and loses its advantage.
query.ToQueryString() to confirm the generated SQL contains a WHERE predicate and no OFFSET clause.EXPLAIN ANALYZE on both the keyset and OFFSET/FETCH queries at a deep page (e.g., offset 500 000). Compare Index Scan vs. Rows Removed by Filter.Do you require true random‑access page numbers (e.g., a UI with page‑number links) or only forward/backward “next/previous” navigation? If random access is mandatory, OFFSET/FETCH with a covering index may be the simpler choice despite its deeper‑page cost.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.