<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Software-Design on Firmino Changani</title>
    <link>https://firmino.work/tags/software-design/</link>
    <description>Recent content in Software-Design on Firmino Changani</description>
    <generator>Hugo -- gohugo.io</generator>
    <language>en-UK</language>
    <managingEditor>hi&#43;blog@firmino.work (Firmino Changani)</managingEditor>
    <webMaster>hi&#43;blog@firmino.work (Firmino Changani)</webMaster>
    <copyright>Firmino Changani</copyright>
    <lastBuildDate>Tue, 07 Jul 2026 00:00:00 +0000</lastBuildDate>
    <atom:link href="https://firmino.work/tags/software-design/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Stripe&#39;s cursor-based pagination</title>
      <link>https://firmino.work/blog/stripes-cursor-based-pagination/</link>
      <pubDate>Tue, 07 Jul 2026 00:00:00 +0000</pubDate><author>hi&#43;blog@firmino.work (Firmino Changani)</author>
      <guid>https://firmino.work/blog/stripes-cursor-based-pagination/</guid>
      <description>Intro While working on a side project that captures and manages audit logs, I had to implement pagination to the endpoint that is supposed to retrieve the existing log entries, let&amp;rsquo;s call it /api/v1/events.&#xA;Returning all audit log entries wasn&amp;rsquo;t and neither was offset-based pagination due to the large volume of data and the rate at which said data was expected to grow.&#xA;Potentially returning gigabytes of data at once was simply not practical, and using offset-based pagination would have led the endpoint to:</description>
      <content:encoded><![CDATA[<h2 id="intro">Intro</h2>
<p>While working on a side project that captures and manages audit logs, I had to implement pagination to the endpoint that
is supposed to retrieve the existing log entries, let&rsquo;s call it <code>/api/v1/events</code>.</p>
<p>Returning all audit log entries wasn&rsquo;t and neither was offset-based pagination due to the large volume of data and the rate
at which said data was expected to grow.</p>
<p>Potentially returning gigabytes of data at once was simply not practical, and using offset-based pagination would have led the endpoint to:</p>
<ul>
<li>Force the database to perform full table scans in order to skip rows as the page number change:
<ul>
<li>Page 1: <code>SELECT * FROM events ORDER BY occurred_at DESC LIMIT 25 OFFSET 0</code></li>
<li>Page 2: <code>... LIMIT 25 OFFSET 50</code></li>
<li>Page 30: <code>... LIMIT 25 OFFSET 750</code></li>
</ul>
</li>
<li>Return inconsistent results per page due to the high rate of writes to the <code>events</code> table:
<ul>
<li>User requests page 1 <code>SELECT * FROM events ORDER BY occurred_at DESC LIMIT 25 OFFSET 0</code>;</li>
<li>New 25 events are inserted before the user clicks requests page 2;</li>
<li>User requests page 2 <code>SELECT * FROM events ORDER BY occurred_at DESC LIMIT 25 OFFSET 25</code>;</li>
<li>Query returns the same results as those from the page 1 because 25 new events have been inserted, and the query is ordering by the column <code>occurred_at DESC</code>;</li>
</ul>
</li>
</ul>
<h2 id="cursor-pagination-it-is">Cursor pagination it is</h2>
<p>Users of the endpoint <code>/api/v1/events</code> do not expect to be able to jump to exact pages, and the <code>id</code> column from the <code>events</code> table stores a ULID, making the column sortable.</p>
<p>With those requirements in mind I just needed to express the pagination API. Good engineers copy, and Stripe&rsquo;s API is one of the best there is in terms of design, so I went to take a look at theirs:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Go to the next page</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">/v1/payment_intents?limit<span class="o">=</span>25<span class="p">&amp;</span><span class="nv">starting_after</span><span class="o">=</span>&lt;id-of-the-last-payment-returned&gt;</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Go to the previous page</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">/v1/payment_intents?limit<span class="o">=</span>25<span class="p">&amp;</span><span class="nv">ending_before</span><span class="o">=</span>&lt;id-of-the-first-payment-returned&gt;</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nt">&#34;data&#34;</span><span class="p">:</span> <span class="p">[],</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;has_more&#34;</span><span class="p">:</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h2 id="starting_after-and-ending_before-in-sql-lingo">starting_after and ending_before in SQL lingo</h2>
<p>The query parameter <code>starting_after</code> takes the <code>id</code> of the last item returned by the endpoint instruct the database to only
query events that come after the given <code>id</code> in descending order:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">events</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="o">?</span><span class="w"> </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">occurred_at</span><span class="w"> </span><span class="k">DESC</span><span class="w"> </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">25</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="c1">-- &#39;?&#39; is the placeholder for starting_after</span></span></span></code></pre></div><p>The query parameter <code>ending_before</code> also takes an <code>id</code> but the first of one of the list from a page higher than <code>1</code> and
instructs the database to only query rows that come before the given <code>id</code> in descending order:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">events</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="o">?</span><span class="w"> </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">occurred_at</span><span class="w"> </span><span class="k">DESC</span><span class="w"> </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">25</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="c1">-- &#39;?&#39; is the placeholder for starting_after</span></span></span></code></pre></div><h2 id="determining-if-there-are-more-rows-to-be-fetched">Determining if there are more rows to be fetched</h2>
<p>The common convention is to fetch just one more row than the limit and discard it:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">events</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="o">?</span><span class="w"> </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">occurred_at</span><span class="w"> </span><span class="k">DESC</span><span class="w"> </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">26</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// golang
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="kn">package</span> <span class="nx">main</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="kd">func</span> <span class="nf">getEvents</span><span class="p">(</span><span class="nx">limit</span> <span class="kt">int</span> <span class="cm">/*other params*/</span><span class="p">)</span> <span class="p">([]</span><span class="nx">Row</span><span class="p">,</span> <span class="kt">bool</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="kd">var</span> <span class="nx">results</span> <span class="p">[]</span><span class="nx">Row</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">	
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">results</span><span class="p">)</span> <span class="p">&gt;</span> <span class="nx">limit</span> <span class="p">{</span> 
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="c1">// result[:len(result)-1] splice from length 0 to the length of
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span>    <span class="c1">// &#39;result&#39; minus 1 to exclude the additional row fetched.
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span>    <span class="k">return</span> <span class="nx">result</span><span class="p">[:</span><span class="nb">len</span><span class="p">(</span><span class="nx">result</span><span class="p">)</span><span class="o">-</span><span class="mi">1</span><span class="p">],</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="k">return</span> <span class="nx">result</span><span class="p">,</span> <span class="kc">false</span>	
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h2 id="do-those-queries-perform-full-table-scans">Do those queries perform full table scans?</h2>
]]></content:encoded>
    </item>
  </channel>
</rss>
