How it works
OFFSET pagination
Section titled “OFFSET pagination”The usual way to read a table by pages is LIMIT and OFFSET:
SELECT * FROM user WHERE enabled = 1 ORDER BY created_at, id LIMIT 100 OFFSET 500000It has two problems on large tables:
- Each page is slower than the previous one. The database still reads and discards every skipped row: page 5,001 reads 500,100 rows to return 100.
- Writes shift the pages. A row inserted or deleted before the current offset moves every following row by one, so some rows are skipped or processed twice. A batch that changes the rows it filters on (disabling the enabled users) skips about half of them.
Keyset pagination
Section titled “Keyset pagination”Keyset (or cursor) pagination remembers the values of the last row of a page, the cursor, and starts the next page right after them:
SELECT * FROM userWHERE enabled = 1 AND created_at >= :createdAt AND (created_at > :createdAt OR (created_at = :createdAt AND id > :id))ORDER BY created_at, idLIMIT 100With an index on (created_at, id), the database seeks to the cursor and reads 100 rows: every page costs the same.
Rows that changed or disappeared behind the cursor do not move it.
One page, step by step
Section titled “One page, step by step”- Query the pageORDER BY + LIMIT, WHERE after the cursor
- Read the cursorclosures on the last row
- Yield the rowsone by one or as a chunk
- Next pageor stop
For each page, CursorPagination:
- Clones your
QueryBuilder, replaces itsORDER BYwith the order configurations and its max results with$maxPerPages. Before the first page, it parses the query once to detect collection joins and scalar selects, and rejects the configurations that would skip rows. - From the second page on, adds the cursor condition: see composite cursors.
- Runs the query through an adapter, Doctrine’s
CursorPaginatoror itsPaginator, and loads the whole page. - Reads the cursor values from the last row of the page, with the closures of the order configurations, before yielding any row: code that changes the ordered fields while iterating does not shift the next page.
- Yields the rows, then queries the next page unless this one was the last:
CursorPaginatorfetches one more row to tell, withPaginatora page with fewer rows than$maxPerPagesis the last one, and a full last page is followed by an empty one.
Without collection joins or arbitrary joins (JOIN Entity e WITH …), that is one query per page. With them, the
fetch-join-collection strategy takes two: see fetch joins and collections.
The cursor is local to each iteration: every foreach, getResults() and getChunkResults() starts from the first
page, even after a previous one was stopped with break, and two interleaved iterations do not share their cursor.
What the cursor does not do
Section titled “What the cursor does not do”- No random access. A page can only be reached from the previous one: there is no “go to page 42”.
getNbPages()is there for progress bars. - No resuming from a stored cursor. To resume an interrupted batch, add a condition yourself, such as
u.id > :lastProcessedId: see batch processing patterns. - Rows moved ahead of the cursor are read again. If the loop changes an ordered field so that the row now sorts
after the cursor (ordering on
u.updatedAtand setting it to now), the row comes back in a later page. Order on fields the loop does not change. - Rows inserted during the run are read if they sort after the cursor, and not if they sort before it.
