WP_Query pagination performance degradation on high offset values
0 reputation · 11 Sept 2021, 16:52 UTC
0 reputation · 11 Sept 2021, 16:52 UTC
When managing large datasets in WordPress, WP_Query utilizes offset-based pagination via the posts_per_page and paged parameters. While this is the standard approach for paginated content, database performance typically declines as the offset increases because the engine must scan through all preceding rows before returning the requested subset.
To mitigate overhead, developers often implement no_found_rows => true to skip the total count calculation and use fields => 'ids' to reduce memory consumption. However, these optimizations do not address the underlying cost of the LIMIT offset, count SQL operation on tables with millions of records.
max_execution_time and MySQL timeouts act as the primary safeguards against hanging queries.WP_Query arguments do not provide a native mechanism for keyset pagination (seek method).What is the recommended strategy for maintaining constant-time query performance in WP_Query as the page number increases? Is there a documented way to implement cursor-based pagination within the core API to avoid high-offset latency?
34775 reputation · 11 Sept 2021, 17:43 UTC
WP_Query has no native cursor or keyset argument — paged and posts_per_page always compile to LIMIT offset, count, and nothing in the core API changes that. The recommended strategy for constant-time deep pagination is keyset (seek) pagination implemented through the posts_where filter: instead of telling MySQL "skip 200,000 rows," you tell it "give me rows after the last one I saw," using an indexed sort column plus a unique tiebreaker.
Confirmed behavior: with LIMIT 200000, 20, MySQL must locate, read, and discard 200,000 rows before returning 20. Cost grows linearly with page depth. no_found_rows => true removes the separate SQL_CALC_FOUND_ROWS count scan, and fields => 'ids' shrinks memory use — both are worth keeping — but neither touches the offset scan itself, which is the dominant cost on deep pages.
Track the sort key of the last row on the current page — typically post_date plus ID as a deterministic tiebreaker — and pass it forward as a cursor (e.g., in the URL or an opaque token). Then inject the seek condition:
add_filter( 'posts_where', function( $where ) {
$cursor = get_cursor_from_request(); // e.g. ['2026-09-01 12:00:00', 48123]
if ( $cursor ) {
global $wpdb;
$where .= $wpdb->prepare(
" AND ( {$wpdb->posts}.post_date, {$wpdb->posts}.ID ) < ( %s, %d )",
$cursor[0], $cursor[1]
);
}
return $where;
});
$query = new WP_Query( [
'posts_per_page' => 20,
'orderby' => [ 'date' => 'DESC', 'ID' => 'DESC' ],
'no_found_rows' => true,
'fields' => 'ids',
] );
The tuple comparison (post_date, ID) < (?, ?) is essential: filtering on post_date alone will skip or duplicate rows whenever multiple posts share a timestamp. With ORDER BY post_date DESC, ID DESC and a matching index, each page fetch examines roughly posts_per_page rows regardless of depth — constant time instead of linear.
If the filter approach feels fragile, using $wpdb directly for the listing query gives you full control over the SQL and avoids interference from other plugins hooking the same filters.
meta_value, the cursor condition can only use an index if a suitable composite index exists — otherwise you may negate the benefit entirely.posts_where is version-sensitive and can conflict with plugins that also rewrite query SQL; scope the filter to only this query (add it immediately before, remove it after).SAVEQUERIES and confirm the generated SQL contains the cursor WHERE clause and no large OFFSET.EXPLAIN on a shallow page and a deep page; rows examined should be roughly equal with keyset, versus growing with offset before the change.One caveat on the index assumption: the examples above assume a standard wp_posts schema where post_date ordering can use an index; if your table is heavily customized, check EXPLAIN output for the actual index used before rolling this out.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.