<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Mahin's Engineering Log]]></title><description><![CDATA[Mahin's Engineering Log]]></description><link>https://mahinr10.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 31 Oct 2026 03:07:19 GMT</lastBuildDate><atom:link href="https://mahinr10.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Exploring Pagination Strategies with PostgreSQL]]></title><description><![CDATA[Exploring PostgreSQL pagination with 30 million records, comparing OFFSET, cursor pagination, and sparse anchors through benchmarks and trade-offs.]]></description><link>https://mahinr10.hashnode.dev/exploring-pagination-strategies-with-postgresql</link><guid isPermaLink="true">https://mahinr10.hashnode.dev/exploring-pagination-strategies-with-postgresql</guid><category><![CDATA[performance]]></category><category><![CDATA[database]]></category><category><![CDATA[backend developments]]></category><dc:creator><![CDATA[Mahin Rashid]]></dc:creator><pubDate>Mon, 19 Oct 2026 14:16:06 GMT</pubDate><content:encoded><![CDATA[<p>Pagination looks simple. Add <code>LIMIT</code> and <code>OFFSET</code>, and the database returns the requested page. But how does this approach behave with millions of records?</p>
<p>To examine this, consider a PostgreSQL table containing <strong>30 million records</strong>, ordered by descending primary-key ID, with 100 records per page.</p>
<h2>1. When OFFSET becomes expensive</h2>
<p>The conventional approach is straightforward:</p>
<pre><code class="language-sql">SELECT *
FROM sample
ORDER BY id DESC
LIMIT 100 OFFSET 1000000;
</code></pre>
<p>However, an index does not allow PostgreSQL to jump directly to the millionth <em>position</em>. It still needs to advance through the preceding entries.</p>
<p>Local measurements using <code>EXPLAIN ANALYZE</code> showed:</p>
<table>
<thead>
<tr>
<th>OFFSET</th>
<th>Execution time</th>
</tr>
</thead>
<tbody><tr>
<td>10,000</td>
<td>2.48 ms</td>
</tr>
<tr>
<td>100,000</td>
<td>14.10 ms</td>
</tr>
<tr>
<td>1,000,000</td>
<td>126.90 ms</td>
</tr>
<tr>
<td>10,000,000</td>
<td>2,299.31 ms</td>
</tr>
<tr>
<td>20,000,000</td>
<td>4,659.87 ms</td>
</tr>
</tbody></table>
<p>Retrieving just 100 records at OFFSET 20 million took approximately <strong>4.66 seconds</strong>.</p>
<p>Beyond latency, unrestricted deep-page requests can also create unnecessary database pressure under concurrent traffic.</p>
<p>Can we avoid scanning the skipped records?</p>
<h2>2. Cursor-based pagination</h2>
<p>What if we navigate through the pages sequentially? Instead of using an OFFSET, we can pass the ID of the last record retrieved on page N as a cursor when requesting page N + 1.</p>
<p>Using this ID in a <code>WHERE</code> clause allows PostgreSQL to seek directly to the relevant position through the index, avoiding the need to scan through records from previous pages.</p>
<pre><code class="language-sql">SELECT *
FROM sample
WHERE id &lt; :last_seen_id
ORDER BY id DESC
LIMIT 100;
</code></pre>
<p>This is keyset pagination, commonly implemented using cursors.</p>
<p>PostgreSQL can seek toward the supplied ID rather than traversing millions of preceding entries.</p>
<p>In the experiments, <strong>all tested indexed-boundary queries executed in under 10 ms</strong>, even at deep positions.</p>
<p>The improvement is substantial, but these measurements assume the boundary ID is already known.</p>
<p><strong>What if a user wants to jump directly from page 5 to page 100,000?</strong></p>
<p>A cursor identifies where to continue, not the starting record of an arbitrary page.</p>
<p>Finding that boundary remains a challenge.</p>
<h2>3. Sparse positional anchors</h2>
<p>We can limit the number of records PostgreSQL needs to skip by introducing an upper bound on OFFSET. Suppose we maintain a separate table that stores the ID of every 10,000th record. We call each of these stored reference points an anchor.</p>
<p>When retrieving a page, we first find the nearest anchor whose stored position does not exceed the requested OFFSET. We then use its ID to seek to that position and scan only the remaining records to reach the requested page.</p>
<p>For 30 million records, this requires approximately <strong>3,000 anchors</strong>.</p>
<p>To retrieve records at OFFSET 10,005,500:</p>
<ol>
<li><p>Find the anchor at position 10,000,000.</p>
</li>
<li><p>Seek to its ID using the primary-key index.</p>
</li>
<li><p>Skip the remaining 5,500 records.</p>
</li>
</ol>
<p>This bounds the local OFFSET to fewer than 10,000 entries.</p>
<p>The anchor-assisted retrieval experiments were encouraging: <strong>all tested queries executed in under 10 ms</strong>, including requests targeting positions millions of records into the dataset.</p>
<p>However, generating the initial 3,000 anchors required numbering 30 million records and took approximately <strong>10.96 seconds</strong>.</p>
<p>More importantly, anchors introduce another problem.</p>
<h2>4. What happens when records change?</h2>
<p>Anchors represent positions, not just IDs.</p>
<p>When a new record is inserted, existing records shift one position deeper. Potentially all 3,000 anchors need updating.</p>
<p>An incremental update experiment completed in under <strong>100 ms</strong>, substantially cheaper than rebuilding the entire anchor table but still potentially expensive for frequent writes.</p>
<p>Deletions shift positions in the opposite direction. Batch operations complicate maintenance further.</p>
<p>Thus, optimizing deep reads introduces <strong>write amplification and consistency challenges</strong>.</p>
<h2>5. A practical trade-off</h2>
<p>For read-heavy applications, exact anchor maintenance may be unnecessary.</p>
<p>One option is to refresh anchors periodically, while accepting temporary positional drift.</p>
<p>For example, inserting 20,000 records at the beginning shifts an existing anchor by approximately 20,000 positions.</p>
<p>Deep-page results may therefore become inaccurate until the next refresh. The positional drift can cause deep-page requests to return overlapping or skipped records relative to their intended page boundaries.</p>
<p>Whether that is acceptable depends on the product.</p>
<p>A hybrid strategy could use cursor pagination for sequential navigation, OFFSET for shallow jumps, and sparse anchors for deep jumps.</p>
<p>The refresh interval and switching threshold should be determined through workload measurements.</p>
<h2>Conclusion</h2>
<p>OFFSET provides simplicity, cursors provide efficient sequential traversal, and sparse anchors enable faster deep jumps.</p>
<p>But every optimization introduces trade-offs involving maintenance, consistency, or complexity.</p>
<p><strong>The objective is not to find the fastest individual query, but to choose a pagination strategy whose overall costs match the application's requirements.</strong></p>
<h2>Further Reading &amp; Acknowledgments</h2>
<p>For a deeper exploration of PostgreSQL pagination strategies, I recommend Joe Nelson's excellent article, <a href="https://begriffs.com/posts/2016-03-30-five-ways-to-paginate.html">Five Ways to Paginate in PostgreSQL</a>.</p>
<p>His discussion of keyset pagination and approximate random access offers valuable perspectives on the trade-offs explored in this article.</p>
<hr />
<p><em>Benchmark disclaimer: These experiments were conducted locally on a 30-million-row PostgreSQL dataset, with other applications running in the background. The environment wasn't isolated or optimised for benchmarking, so don't take the exact timings too seriously. The numbers are intended to illustrate relative performance trends, not establish reproducible performance guarantees.</em></p>
]]></content:encoded></item></channel></rss>