<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: hidetzu</title>
    <description>The latest articles on DEV Community by hidetzu (@hidetzu).</description>
    <link>https://dev.to/hidetzu</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4111780%2Ffd639b78-865a-4fbc-af21-fbca6835181d.jpg</url>
      <title>DEV Community: hidetzu</title>
      <link>https://dev.to/hidetzu</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hidetzu"/>
    <language>en</language>
    <item>
      <title>The Same Row Twice: Four Things Keyset Pagination Needs</title>
      <dc:creator>hidetzu</dc:creator>
      <pubDate>Thu, 17 Sep 2026 12:47:40 +0000</pubDate>
      <link>https://dev.to/hidetzu/the-same-row-twice-four-things-keyset-pagination-needs-2ecp</link>
      <guid>https://dev.to/hidetzu/the-same-row-twice-four-things-keyset-pagination-needs-2ecp</guid>
      <description>&lt;p&gt;Speed is OFFSET's second problem. The first is that page 2 can hand you a row you already read on page 1 — and no index will fix it, because it is not a performance bug.&lt;/p&gt;

&lt;p&gt;Part 1 of this series measured why deep OFFSET is O(depth): at depth 9,999,980 PostgreSQL reads ten million rows and discards all but twenty. This is the other half — why you would switch even on a table small enough that nobody notices the speed, and the four things a keyset cursor needs before it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Page 2 shows you page 1's row
&lt;/h2&gt;

&lt;p&gt;Ten rows, three per page, newest first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TEMP&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'item'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;generate_series&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="k"&gt;OFFSET&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; id |  name
----+--------
 10 | item10
  9 | item9
  8 | item8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now one row arrives before the user taps "next":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'item11'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="k"&gt;OFFSET&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; id | name
----+-------
  8 | item8
  7 | item7
  6 | item6
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;item8&lt;/code&gt; came back. OFFSET 3 does not mean "after the three you saw" — it means "the 4th onward &lt;em&gt;in the ordering as it stands now&lt;/em&gt;". One insert above the window shifted everything down by one.&lt;/p&gt;

&lt;p&gt;Delete a row instead and the same arithmetic runs backwards: a row slides up past the boundary and is never shown at all. The user cannot reach it by paging.&lt;/p&gt;

&lt;p&gt;A cursor asks a different question:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; id | name
----+-------
  7 | item7
  6 | item6
  5 | item5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Correct, because the cursor names a &lt;strong&gt;position&lt;/strong&gt; (&lt;code&gt;id = 8&lt;/code&gt;), not a &lt;strong&gt;rank&lt;/strong&gt; ("the 4th"). Other people's inserts move ranks. They do not move positions.&lt;/p&gt;

&lt;p&gt;This is the duplicated post in your infinite-scroll timeline. It is OFFSET's semantics, so it survives every index you add.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where keyset still breaks
&lt;/h3&gt;

&lt;p&gt;Keyset is not immune to everything, and it is worth being precise about what it fixes. It removes boundary drift caused by rows appearing or disappearing &lt;em&gt;elsewhere&lt;/em&gt; in the set, because those rows change ranks and the cursor does not use ranks.&lt;/p&gt;

&lt;p&gt;It does not help when the sort key of a row you already passed changes. Order a feed by &lt;code&gt;updated_at DESC&lt;/code&gt;, let someone edit an old post, and that row jumps ahead of your cursor — you will see it a second time. Order by &lt;code&gt;total_amount&lt;/code&gt; and let an amount change, and a row can move behind your cursor and never be returned.&lt;/p&gt;

&lt;p&gt;The rule that falls out of this: &lt;strong&gt;order by something that does not move.&lt;/strong&gt; &lt;code&gt;created_at&lt;/code&gt; plus a primary key is a good default precisely because neither value is ever rewritten. If the product genuinely needs "most recently updated first", accept that pagination over it is approximate and say so, rather than pretending a cursor made it exact.&lt;/p&gt;

&lt;p&gt;So: four things.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A tiebreaker, as a row comparison
&lt;/h2&gt;

&lt;p&gt;A cursor on &lt;code&gt;created_at&lt;/code&gt; alone breaks the moment two rows share a value. In my 10M-row table they do, by construction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="mi"&gt;5000001&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="mi"&gt;5000100&lt;/span&gt; &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;       created_at       | count
------------------------+-------
 2025-01-01 13:53:20+00 |   100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One hundred rows on the same timestamp. &lt;code&gt;WHERE created_at &amp;lt; :cursor&lt;/code&gt; cannot express &lt;em&gt;where inside those hundred&lt;/em&gt; the last page ended, so you get duplicates or gaps — the exact problem you switched to fix.&lt;/p&gt;

&lt;p&gt;Add the primary key as a tiebreaker, and write it as a row comparison:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="p"&gt;(:&lt;/span&gt;&lt;span class="n"&gt;last_created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;last_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;(a, b) &amp;lt; (x, y)&lt;/code&gt; means "a &amp;lt; x, or a = x and b &amp;lt; y". Writing it that way is not cosmetic: PostgreSQL can push a row comparison into a composite index as a single &lt;code&gt;Index Cond&lt;/code&gt;. Expand it into &lt;code&gt;OR&lt;/code&gt; by hand and you can lose the index.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. An index that satisfies the column order &lt;em&gt;and&lt;/em&gt; the directions
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;orders_created_at_id_desc_idx&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that in place, at the same depth that cost OFFSET 110,659 pages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Limit  (actual time=0.026..0.028 rows=20 loops=1)
   -&amp;gt;  Index Only Scan using orders_created_at_id_desc_idx on orders
         Index Cond: (ROW(created_at, id) &amp;lt; ROW('2025-01-01 13:53:20+00', 5000001))
         Heap Fetches: 0
         Buffers: shared read=4
 Execution Time: 0.062 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four pages. 0.062 ms.&lt;/p&gt;

&lt;p&gt;The common advice — "a DESC query needs a DESC index" — is not quite it. A B-tree walks backwards perfectly well, so &lt;strong&gt;flipping every column at once is free&lt;/strong&gt;. Ask this DESC/DESC index for &lt;code&gt;ORDER BY created_at ASC, id ASC&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; -&amp;gt;  Index Only Scan Backward using orders_created_at_id_desc_idx on orders
       (actual time=0.015..0.016 rows=20 loops=1)
 Execution Time: 0.030 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Backward&lt;/code&gt;, and no sort node.&lt;/p&gt;

&lt;p&gt;What breaks is &lt;strong&gt;mixing directions between columns&lt;/strong&gt;. &lt;code&gt;ORDER BY created_at DESC, id ASC&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; -&amp;gt;  Incremental Sort  (actual time=0.088..0.089 rows=20 loops=1)
       Sort Key: created_at DESC, id
       -&amp;gt;  Index Only Scan using orders_created_at_id_desc_idx on orders
             (actual time=0.015..0.020 rows=101 loops=1)
 Execution Time: 0.105 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An &lt;code&gt;Incremental Sort&lt;/code&gt; appears. Here it is cheap — only 101 rows, because ties run 100 deep — and it would be easy to look at 0.105 ms and move on.&lt;/p&gt;

&lt;p&gt;For keyset, that timing badly understates the damage. The sort is the visible symptom; the real loss is that with directions mixed, &lt;code&gt;(created_at, id) &amp;lt; (...)&lt;/code&gt; no longer collapses into a single &lt;code&gt;Index Cond&lt;/code&gt;. The cursor predicate stops being something the index can seek on, which is the entire mechanism that made the query independent of depth. You are back to scanning and filtering — the shape of the problem part 1 was about, reintroduced by a sort clause that looked harmless.&lt;/p&gt;

&lt;p&gt;The requirement is not "make it DESC". It is: &lt;strong&gt;a B-tree matching your column order and your direction pattern.&lt;/strong&gt; Want mixed directions? That is a second index.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Cursors clients cannot read
&lt;/h2&gt;

&lt;p&gt;Expose &lt;code&gt;?after_id=9999980&lt;/code&gt; and within a week someone's client is doing &lt;code&gt;+1&lt;/code&gt; to get the next page. You can never change the sort order again.&lt;/p&gt;

&lt;p&gt;Encode the whole cursor and make it opaque:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;CreatedAt&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Time&lt;/span&gt; &lt;span class="s"&gt;`json:"c"`&lt;/span&gt;
    &lt;span class="n"&gt;ID&lt;/span&gt;        &lt;span class="kt"&gt;int64&lt;/span&gt;     &lt;span class="s"&gt;`json:"i"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Marshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RawURLEncoding&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EncodeToString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Base64 is encoding, not encryption — readable and forgeable. Add an HMAC if you need to reject tampering. Include which sort order issued the cursor, so switching order invalidates old ones instead of silently returning nonsense.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Total count is a separate problem
&lt;/h2&gt;

&lt;p&gt;"Page 1 of 1,234" needs this, and keyset does nothing for it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Finalize Aggregate  (actual time=243.851..248.230 rows=1 loops=1)
   -&amp;gt;  Gather  (actual time=243.751..248.222 rows=3 loops=1)
         Workers Planned: 2
         -&amp;gt;  Partial Aggregate  (actual time=234.020..234.020 rows=1 loops=3)
               -&amp;gt;  Parallel Seq Scan on orders
                     (actual time=0.009..129.056 rows=3333333 loops=3)
 Execution Time: 259.813 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;260 ms across three processes — a leader plus two workers (&lt;code&gt;max_parallel_workers_per_gather&lt;/code&gt; is 2 by default; &lt;code&gt;loops=3&lt;/code&gt; is those three, not all 16 threads).&lt;/p&gt;

&lt;p&gt;The fix is to want it less. Usually the UI only needs "is there more", which is &lt;code&gt;LIMIT 21&lt;/code&gt; and checking whether a 21st row came back — you discard the extra row and report its existence as a boolean.&lt;/p&gt;

&lt;p&gt;That falls out of the response shape a cursor API wants anyway:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"...20 rows..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"next_cursor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"eyJjIjoiMjAyNS0wMS0wMVQxMzo1MzoyMFoiLCJpIjo1MDAwMDAxfQ"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"has_more"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No total, no page number, and &lt;code&gt;next_cursor&lt;/code&gt; is null on the last page. If an estimate is good enough for a "roughly N results" label, &lt;code&gt;pg_class.reltuples&lt;/code&gt; is free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one to use
&lt;/h2&gt;

&lt;p&gt;OFFSET is not a mistake. It has a domain.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;OFFSET&lt;/th&gt;
&lt;th&gt;Cursor (keyset)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Deep pages&lt;/td&gt;
&lt;td&gt;O(depth)&lt;/td&gt;
&lt;td&gt;Flat&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jump to page N&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No — next/prev only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total pages&lt;/td&gt;
&lt;td&gt;Yes (count costs extra)&lt;/td&gt;
&lt;td&gt;Usually not offered&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Concurrent writes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Duplicates and gaps&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Robust to inserts/deletes elsewhere&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build cost&lt;/td&gt;
&lt;td&gt;Nothing&lt;/td&gt;
&lt;td&gt;Tiebreaker, index, encoding&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Switchable sort order&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;An index and cursor format per order&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Stay on OFFSET&lt;/strong&gt; when the result set tops out in the thousands, when page numbers go in URLs to be bookmarked and shared, when jumping to an arbitrary page is a real requirement, or when the data is frozen while being read (a closed report).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use a cursor&lt;/strong&gt; for infinite scroll, timelines and notification feeds; for exports and batch reads; for public APIs where you do not control how deep clients go; and for anything on a table that is being written to continuously.&lt;/p&gt;

&lt;p&gt;They also coexist. Page-numbered admin screens on OFFSET and a cursor-based public API is a normal split. Just cap the depth on the OFFSET side — an endpoint that accepts &lt;code&gt;offset=9999980&lt;/code&gt; from anyone is a free denial-of-service handle, as part 1's 110,659 pages per request should make obvious.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Switch off OFFSET when rows shift under your users, not when the page feels slow — and budget for a tiebreaker, an index per sort order, an opaque cursor, and a separate answer for "how many".&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>sql</category>
      <category>api</category>
      <category>database</category>
    </item>
    <item>
      <title>OFFSET Doesn't Skip Rows: What 10M Rows in PostgreSQL Actually Cost</title>
      <dc:creator>hidetzu</dc:creator>
      <pubDate>Tue, 15 Sep 2026 22:46:12 +0000</pubDate>
      <link>https://dev.to/hidetzu/offset-doesnt-skip-rows-what-10m-rows-in-postgresql-actually-cost-2ln2</link>
      <guid>https://dev.to/hidetzu/offset-doesnt-skip-rows-what-10m-rows-in-postgresql-actually-cost-2ln2</guid>
      <description>&lt;p&gt;&lt;code&gt;OFFSET 9999980 LIMIT 20&lt;/code&gt; does not skip 9,999,980 rows. It reads every one of them, builds each tuple, and throws it away. To hand back 20 rows it touches 110,659 buffer pages — 865 MB of a 1.2 GB table — and &lt;code&gt;EXPLAIN&lt;/code&gt; reports it without flinching.&lt;/p&gt;

&lt;p&gt;I knew deep pages were slow. Asked to explain &lt;em&gt;why&lt;/em&gt;, I said "it skips ahead, and skipping that far costs something" — which is wrong in the one way that matters. Nothing jumps. So I loaded 10 million rows into PostgreSQL and measured it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;PostgreSQL 17.11 (&lt;code&gt;postgres:17-alpine&lt;/code&gt;), Docker 29.4.1, Ryzen 7 5700X, 78 GB RAM, &lt;code&gt;shared_buffers&lt;/code&gt; left at the default 128 MB.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;           &lt;span class="nb"&gt;bigint&lt;/span&gt;       &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;   &lt;span class="n"&gt;timestamptz&lt;/span&gt;  &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;customer_id&lt;/span&gt;  &lt;span class="nb"&gt;bigint&lt;/span&gt;       &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt;       &lt;span class="nb"&gt;varchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;total_amount&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt;      &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;orders_created_at_id_desc_idx&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten million generated rows, with one deliberate property: &lt;code&gt;created_at&lt;/code&gt; repeats every 100 rows. Real order tables have ties within the same second, and that matters in part 2.&lt;/p&gt;

&lt;p&gt;Keep these physical sizes in mind — the &lt;code&gt;Buffers&lt;/code&gt; numbers later resolve straight back into them at 8 KB per page:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Object&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;Pages&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;orders&lt;/code&gt; heap&lt;/td&gt;
&lt;td&gt;651 MB&lt;/td&gt;
&lt;td&gt;~83,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;orders_pkey&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;214 MB&lt;/td&gt;
&lt;td&gt;~27,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;orders_created_at_id_desc_idx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;301 MB&lt;/td&gt;
&lt;td&gt;~38,000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every query below returns exactly 20 rows and selects only &lt;code&gt;id, created_at&lt;/code&gt;. Otherwise you cannot tell which difference came from the pagination method.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the numbers
&lt;/h2&gt;

&lt;p&gt;Five runs each, same session, no &lt;code&gt;EXPLAIN&lt;/code&gt; attached:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Depth&lt;/th&gt;
&lt;th&gt;OFFSET&lt;/th&gt;
&lt;th&gt;Cursor&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0.292 ms&lt;/td&gt;
&lt;td&gt;0.130 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;99,980&lt;/td&gt;
&lt;td&gt;7.009 ms&lt;/td&gt;
&lt;td&gt;0.027 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;999,980&lt;/td&gt;
&lt;td&gt;68.370 ms&lt;/td&gt;
&lt;td&gt;0.020 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4,999,980&lt;/td&gt;
&lt;td&gt;396.669 ms&lt;/td&gt;
&lt;td&gt;0.021 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9,999,980&lt;/td&gt;
&lt;td&gt;791.349 ms&lt;/td&gt;
&lt;td&gt;0.016 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The ratio at the bottom row is about 49,000x, and it is the least interesting number here. Look at the shape of each column instead. Ten times the depth, ten times the time — OFFSET is cleanly linear. The cursor column does not move at all across a 100,000x change in depth.&lt;/p&gt;

&lt;p&gt;This is not a constant factor. It is a different complexity class.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; The cursor timings sit at 0.016–0.027 ms, close to measurement resolution. Do not read the 49,000x as precise. The claim is that the column is flat.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fktb3ps2ad22nc0v0grsk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fktb3ps2ad22nc0v0grsk.png" alt="Diagram 1" width="799" height="160"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  EXPLAIN says it out loud
&lt;/h2&gt;

&lt;p&gt;The plain OFFSET at depth 9,999,980:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Limit  (cost=343020.75..343021.43 rows=20 width=16)
        (actual time=1171.800..1171.803 rows=20 loops=1)
   Buffers: shared read=110659
   -&amp;gt;  Index Scan using orders_pkey on orders
        (actual time=0.027..934.060 rows=10000000 loops=1)
         Buffers: shared read=110659
 Execution Time: 1172.019 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things to read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;Limit&lt;/code&gt; node returns 20 rows. Its child returns 10,000,000.&lt;/strong&gt; That gap is the whole story. &lt;code&gt;Index Scan&lt;/code&gt; produced ten million rows; &lt;code&gt;Limit&lt;/code&gt; discarded 9,999,980 of them and passed 20 up. Read, then discarded — not skipped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Buffers&lt;/code&gt; scales with depth, not with the result size:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Depth&lt;/th&gt;
&lt;th&gt;Index Scan rows&lt;/th&gt;
&lt;th&gt;Buffers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;99,980&lt;/td&gt;
&lt;td&gt;100,000&lt;/td&gt;
&lt;td&gt;1,110&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;999,980&lt;/td&gt;
&lt;td&gt;1,000,000&lt;/td&gt;
&lt;td&gt;11,069&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4,999,980&lt;/td&gt;
&lt;td&gt;5,000,000&lt;/td&gt;
&lt;td&gt;55,331&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9,999,980&lt;/td&gt;
&lt;td&gt;10,000,000&lt;/td&gt;
&lt;td&gt;110,659&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Now put 110,659 next to the table above. The primary key index is ~27,000 pages and the heap is ~83,000. They sum to ~110,000. The query walked the entire index &lt;em&gt;and&lt;/em&gt; the entire heap to return a few hundred bytes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Most of the time is the discarding.&lt;/strong&gt; &lt;code&gt;Index Scan&lt;/code&gt; alone is 934 ms of the 1,172 ms total.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; The average table said 791 ms for this query; this plan says 1,172 ms. Different instruments. The table is five bare runs; this is one run under &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt;, which timestamps every node and charges more the more rows pass through. Compare absolute numbers within the table, and structure within the plans. Every comparison below is plan-to-plan.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why it can only count
&lt;/h2&gt;

&lt;p&gt;An index maps &lt;strong&gt;key to location&lt;/strong&gt;. "Where is &lt;code&gt;id = 9999980&lt;/code&gt;" takes a handful of node lookups from the root. OFFSET asks something else:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which row is the 9,999,981st when sorted?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A PostgreSQL B-tree cannot answer that, because rank is not stored anywhere in it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgygl25v0wrquttyhsd8w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgygl25v0wrquttyhsd8w.png" alt="Diagram 2" width="800" height="490"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every node knows which key ranges lie beneath it. None of them knows &lt;em&gt;how many&lt;/em&gt; entries do. If they did, you could descend by rank — "the left subtree holds 5 million, so go right" — and deep OFFSET would cost the same as a cursor. Such structures exist; order-statistic trees and ranked B-trees are exactly this. PostgreSQL's B-tree is not one.&lt;/p&gt;

&lt;p&gt;The leaves are chained together in key order, so walking the whole set is easy. Walking is the only thing on offer.&lt;/p&gt;

&lt;p&gt;And there is a second layer. &lt;strong&gt;MVCC visibility is not in the index.&lt;/strong&gt; Whether an index entry is visible to your transaction generally requires checking the heap (the visibility map can skip this when a page is known all-visible). So OFFSET does not want the Nth entry. It wants &lt;em&gt;the Nth row currently visible to you&lt;/em&gt;. Even a subtree counter would count entries, not visible rows.&lt;/p&gt;

&lt;p&gt;That leaves exactly one strategy: walk from the start and count.&lt;/p&gt;

&lt;p&gt;OFFSET's cost is not a missing optimisation. It falls straight out of a structure that does not store rank.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the discarding cheaper
&lt;/h2&gt;

&lt;p&gt;Discarding has internals worth separating. The scan above also returns &lt;code&gt;created_at&lt;/code&gt;, which is not in the primary key index — so each of the ten million discarded rows triggers a heap visit. That part is pure waste.&lt;/p&gt;

&lt;p&gt;Skip on &lt;code&gt;id&lt;/code&gt; alone, then fetch the heap for the surviving 20. This is the deferred join:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="k"&gt;OFFSET&lt;/span&gt; &lt;span class="mi"&gt;9999980&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Nested Loop  (actual time=799.685..799.704 rows=20 loops=1)
   Buffers: shared hit=82 read=27326
   -&amp;gt;  Limit  (actual time=799.620..799.623 rows=20 loops=1)
         -&amp;gt;  Index Only Scan using orders_pkey on orders
               (actual time=0.023..561.950 rows=10000000 loops=1)
               Heap Fetches: 0
 Execution Time: 800.047 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Heap Fetches: 0&lt;/code&gt;, and buffers drop to 27,408 — almost exactly the ~27,000-page primary key index and nothing else. The heap is gone from the scan entirely.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plan-to-plan&lt;/th&gt;
&lt;th&gt;Plain OFFSET&lt;/th&gt;
&lt;th&gt;Deferred join&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Buffers&lt;/td&gt;
&lt;td&gt;110,659&lt;/td&gt;
&lt;td&gt;27,408&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1/4&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Execution time&lt;/td&gt;
&lt;td&gt;1,172 ms&lt;/td&gt;
&lt;td&gt;800 ms&lt;/td&gt;
&lt;td&gt;2/3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here is the part I did not expect. &lt;strong&gt;Pages fell to a quarter; time only fell to two thirds.&lt;/strong&gt; Cutting I/O did not cut time proportionally, which means the dominant cost was never the pages. The Index Only Scan alone spends 562 ms walking 10 million entries: about 56 ns per row, just to produce a tuple and drop it.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;rows=10000000&lt;/code&gt; is unchanged. The constant improved. The order is still O(depth).&lt;/p&gt;

&lt;h2&gt;
  
  
  It is not the disk
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Buffers: shared read&lt;/code&gt; only means "not in PostgreSQL's shared buffers." Whether it reached the physical device or came from the OS page cache is a separate question — and a measurable one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;track_io_timing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Limit  (actual time=1176.118..1176.121 rows=20 loops=1)
   Buffers: shared read=110659
   I/O Timings: shared read=133.496
 Execution Time: 1176.317 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;110,659 pages cost &lt;strong&gt;133 ms of I/O — 11% of the runtime&lt;/strong&gt;, about 1.2 µs per page. With 1.2 GB of data on a host with 78 GB of RAM, those reads were almost certainly served by the OS page cache.&lt;/p&gt;

&lt;p&gt;The other ~1,040 ms is not time PostgreSQL attributed to reading anything. It is scanning ten million index entries, forming tuples, and discarding them.&lt;/p&gt;

&lt;p&gt;So "OFFSET is slow because disks are slow" does not survive contact with the measurement. Faster storage buys back roughly a tenth of it. The O(depth) term is CPU work, and it stays.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;OFFSET is not skipping. It is reading and discarding, because nothing in a B-tree can answer "which row is Nth right now" — and a cursor is fast not because it is cleverer, but because it replaces that question with "which rows come after this key," which an index &lt;em&gt;can&lt;/em&gt; answer.&lt;/p&gt;

&lt;p&gt;Part 2 is the other half: the reason to switch is usually not speed at all, and the four things keyset pagination needs before it works.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>sql</category>
      <category>performance</category>
      <category>database</category>
    </item>
    <item>
      <title>The Tests Passed. The Function Already Existed.</title>
      <dc:creator>hidetzu</dc:creator>
      <pubDate>Tue, 15 Sep 2026 22:12:03 +0000</pubDate>
      <link>https://dev.to/hidetzu/the-tests-passed-the-function-already-existed-35k1</link>
      <guid>https://dev.to/hidetzu/the-tests-passed-the-function-already-existed-35k1</guid>
      <description>&lt;p&gt;Both models passed the tests. Both rewrote a function that was sitting in their own prompt, twenty lines up.&lt;/p&gt;

&lt;p&gt;That is a worked example from the RepoExec paper, and it is the reason the benchmark exists. &lt;code&gt;pass@k&lt;/code&gt; cannot see it — the tests are green. The bill arrives six months later, when you change the function that was supposed to be the only implementation and the change does not take everywhere.&lt;/p&gt;

&lt;p&gt;RepoExec (&lt;a href="https://aclanthology.org/2025.findings-naacl.82/" rel="noopener noreferrer"&gt;NAACL 2025 Findings&lt;/a&gt;, Nam Le Hai, Dung Manh Nguyen, Nghi D. Q. Bui, FPT Software AI Center) adds a second axis next to correctness: of the dependencies you handed the model, how many did it actually call. Across 18 models the best pass@1 is 42.57%.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two axes, not one
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbspdwbmisnicszb3i3ea.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbspdwbmisnicszb3i3ea.png" alt="Diagram 1" width="798" height="174"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The second axis is the new one. &lt;strong&gt;Dependency Invocation Rate (DIR)&lt;/strong&gt; is the share of provided dependencies that appear in the generated code. With &lt;code&gt;Dg&lt;/code&gt; the set of identifiers in the output and &lt;code&gt;Ds&lt;/code&gt; the set of dependencies extracted from the reference solution:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DIR = |Dg ∩ Ds| / |Ds|
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Match-based metrics like BLEU weigh every token the same, so a valid alternative implementation scores badly for no good reason. DIR only looks at the identifiers that name dependencies. Calling &lt;code&gt;_create_future()&lt;/code&gt; instead of &lt;code&gt;Future()&lt;/code&gt; is not a stylistic variant — it is the choice the author of the repository already made.&lt;/p&gt;

&lt;p&gt;The benchmark is 355 Python problems, each with an executable environment. Tests are LLM-generated, then filtered for syntax and execution and pushed for coverage: 99.45 test cases and 96.25% line coverage per problem on average. Prompts are short — 362.92 tokens on average with full dependency bodies, 253.05 with bodies stripped. 22.8% involve a cross-file dependency.&lt;/p&gt;

&lt;p&gt;The coverage push is worth noting on its own. Strengthening the tests dropped pass@1 by over 5 points. Solutions that had been scoring as correct started failing, and the paper's diagnosis is that most of them had ignored the supplied context entirely and solved the natural-language description instead. They failed at the edges — which is exactly where the repository's own validation helpers live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nobody is close
&lt;/h2&gt;

&lt;p&gt;Full context, ordered by pass@1:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;pass@1&lt;/th&gt;
&lt;th&gt;DIR&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek-R1&lt;/td&gt;
&lt;td&gt;42.57&lt;/td&gt;
&lt;td&gt;70.86&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek-V3&lt;/td&gt;
&lt;td&gt;42.00&lt;/td&gt;
&lt;td&gt;80.35&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CodeLlama-34b-Python&lt;/td&gt;
&lt;td&gt;40.93&lt;/td&gt;
&lt;td&gt;68.85&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CodeLlama-13b-Python&lt;/td&gt;
&lt;td&gt;38.65&lt;/td&gt;
&lt;td&gt;62.26&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-4o&lt;/td&gt;
&lt;td&gt;37.14&lt;/td&gt;
&lt;td&gt;81.43&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-4o-mini&lt;/td&gt;
&lt;td&gt;30.29&lt;/td&gt;
&lt;td&gt;74.75&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-3.5&lt;/td&gt;
&lt;td&gt;27.27&lt;/td&gt;
&lt;td&gt;63.59&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every model is under 50% pass@1 on 355 problems with a single level of dependencies supplied.&lt;/p&gt;

&lt;p&gt;Look at GPT-4o-mini. DIR 74.75, pass@1 30.29. It reaches for the dependencies and gets the call wrong. DIR counts whether a name appeared, not whether it was used correctly, which is precisely why the paper insists on reading both numbers together. Cross them and you get four quadrants:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Low DIR — ignores the deps&lt;/th&gt;
&lt;th&gt;High DIR — uses the deps&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;High pass@1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Works, but reimplements — debt&lt;/td&gt;
&lt;td&gt;The target&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Low pass@1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Just broken&lt;/td&gt;
&lt;td&gt;Over-built, over-complicated&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The paper's finding is that the two model families fail on opposite diagonals. Pretrained models land top-left: correct code that quietly rewrites what was already there. Instruction-tuned models land bottom-right: they find the dependency, then wrap it in machinery nobody asked for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Half the context was worse than none
&lt;/h2&gt;

&lt;p&gt;Dependencies are supplied at three levels. &lt;strong&gt;Full&lt;/strong&gt; is signature + docstring + body; &lt;strong&gt;Medium&lt;/strong&gt; is signature + docstring; &lt;strong&gt;Small&lt;/strong&gt; is signature only. Information content runs Full &amp;gt; Medium &amp;gt; Small. Performance does not (BasePrompt, pass@1):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Full&lt;/th&gt;
&lt;th&gt;Medium&lt;/th&gt;
&lt;th&gt;Small&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CodeLlama-13b-Python&lt;/td&gt;
&lt;td&gt;38.65&lt;/td&gt;
&lt;td&gt;32.96&lt;/td&gt;
&lt;td&gt;35.66&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;StarCoder&lt;/td&gt;
&lt;td&gt;28.08&lt;/td&gt;
&lt;td&gt;22.54&lt;/td&gt;
&lt;td&gt;25.54&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;StarCoder2-15b&lt;/td&gt;
&lt;td&gt;27.77&lt;/td&gt;
&lt;td&gt;18.70&lt;/td&gt;
&lt;td&gt;23.27&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phi-2&lt;/td&gt;
&lt;td&gt;19.04&lt;/td&gt;
&lt;td&gt;14.54&lt;/td&gt;
&lt;td&gt;14.82&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Full &amp;gt; Small &amp;gt; Medium, every time. Trimming halfway is worse than trimming all the way.&lt;/p&gt;

&lt;p&gt;The explanation is the good part. A Medium-context dependency — signature, docstring, no body — has exactly the same shape as the target function's prompt. So the model reads the context as a run of few-shot examples with the last one blank, and treats the job as filling in the blank. The evidence is empty output: with Medium context, over 31% of StarCoder2's generations are empty function bodies. It did not lack information. It misread what the information was for.&lt;/p&gt;

&lt;p&gt;That kills the "more context vs. less context" framing outright. What moved the numbers was not volume but &lt;strong&gt;what the supplied text looks like it is asking for&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The example
&lt;/h2&gt;

&lt;p&gt;The Tornado case. &lt;code&gt;maybe_future&lt;/code&gt; converts a value into a &lt;code&gt;Future&lt;/code&gt;. The prompt supplies &lt;code&gt;_create_future()&lt;/code&gt;, which builds a &lt;code&gt;Future&lt;/code&gt; and then strips the extra asyncio debug stack entries the wrapper itself introduced.&lt;/p&gt;

&lt;p&gt;The reference solution:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;maybe_future&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;is_future&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;fut&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_create_future&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;fut&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_result&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fut&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pretrained model — tests pass:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;maybe_future&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;is_future&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;
    &lt;span class="n"&gt;future&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;          &lt;span class="c1"&gt;# builds it by hand, skipping _create_future()
&lt;/span&gt;    &lt;span class="n"&gt;future&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_result&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;future&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The instruction-tuned model — tests also pass:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;maybe_future&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;   &lt;span class="c1"&gt;# is_future() is right there
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="nf"&gt;isawaitable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;        &lt;span class="c1"&gt;# a branch the spec never asked for
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ensure_future&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;future&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;future&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_result&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;future&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Neither calls &lt;code&gt;_create_future()&lt;/code&gt;. The entire reason that function exists — the debug-info cleanup — is gone from both. The paper calls this a failure to optimise memory use and flags it as code smell and technical debt.&lt;/p&gt;

&lt;p&gt;And no test catches it. Only DIR reports that &lt;code&gt;_create_future&lt;/code&gt; was never invoked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things that helped
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Show it the errors.&lt;/strong&gt; Feed back the failing test output, up to three rounds:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Round&lt;/th&gt;
&lt;th&gt;GPT-3.5&lt;/th&gt;
&lt;th&gt;WizardCoder&lt;/th&gt;
&lt;th&gt;CodeLlama-13b-Python&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;27.04&lt;/td&gt;
&lt;td&gt;34.37&lt;/td&gt;
&lt;td&gt;39.44&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;36.34&lt;/td&gt;
&lt;td&gt;40.85&lt;/td&gt;
&lt;td&gt;39.44&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;40.00&lt;/td&gt;
&lt;td&gt;41.69&lt;/td&gt;
&lt;td&gt;39.44&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;41.97&lt;/td&gt;
&lt;td&gt;42.54&lt;/td&gt;
&lt;td&gt;39.44&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;GPT-3.5 gains nearly 15 points, and DIR rises by over 7 points as well — debugging improves dependency use, not just correctness. CodeLlama-13b-Python does not move a single digit across three rounds. Whether an error log is worth sending back is a per-model fact, which matters if you are wiring an agent loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fine-tune on dependency-annotated data.&lt;/strong&gt; The authors built a set from 1,555 repositories and 154,818 functions and LoRA-tuned on it (DepIT):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Full pass@1&lt;/th&gt;
&lt;th&gt;Full DIR&lt;/th&gt;
&lt;th&gt;Small pass@1&lt;/th&gt;
&lt;th&gt;Small DIR&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Phi-2&lt;/td&gt;
&lt;td&gt;19.04&lt;/td&gt;
&lt;td&gt;48.22&lt;/td&gt;
&lt;td&gt;14.82&lt;/td&gt;
&lt;td&gt;44.54&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phi-2 + DepIT&lt;/td&gt;
&lt;td&gt;20.20&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;61.66&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;20.31&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;70.30&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;StarCoder2&lt;/td&gt;
&lt;td&gt;27.77&lt;/td&gt;
&lt;td&gt;60.57&lt;/td&gt;
&lt;td&gt;23.27&lt;/td&gt;
&lt;td&gt;53.49&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;StarCoder2 + DepIT&lt;/td&gt;
&lt;td&gt;28.45&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;69.76&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;27.27&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;73.98&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;pass@1 barely moves. DIR jumps 10-25 points. Using what is already there is a separable, learnable skill.&lt;/p&gt;

&lt;p&gt;The part I keep thinking about: after tuning, Small context catches up with Full. Phi-2 scores higher on both metrics with signatures alone. That is two models, not a general law — but it says a signature and a good name can be enough to get a dependency called, without shipping its body.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this stops
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One level of dependencies.&lt;/strong&gt; The paper lists this first in its own limitations: only what the target function calls directly, no transitive graph. Input-length budget. Real comprehension goes deeper.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Python, 355 problems, one function at a time.&lt;/strong&gt; Not multi-file changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tests are LLM-generated.&lt;/strong&gt; High coverage, but not human-annotated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DIR only counts invocation.&lt;/strong&gt; Whether the call was correct needs pass@1 alongside it, and calling a dependency that should not have been called is not measured at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Early-2025 models.&lt;/strong&gt; These are DeepSeek-R1 and GPT-4o-era numbers. Do not carry 42.57% forward to whatever you are using today.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Context length barely correlates with score.&lt;/strong&gt; Model size and family dominate. With 363-token prompts there is no haystack to search — the dependency extractor already did the selecting. Being able to hold a long context and being able to pick the right one are different jobs, and only the first belongs to the model.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd change in a repo
&lt;/h2&gt;

&lt;p&gt;Two things follow for me, and neither is exotic.&lt;/p&gt;

&lt;p&gt;Invest in the public face — names, signatures, docstrings written from the caller's side. That has always been called good design; DIR just converts "readable" into "how often an LLM reuses this", which is a number you can move. For a function that breaks something when it is skipped, put the reason in the docstring: not "checks for a string" but "all str checks go through this so validation stays in one place."&lt;/p&gt;

&lt;p&gt;And stop treating a green test run as a review signal for generated code. Two of the paper's three worked examples reimplement a supplied dependency while passing. The question worth asking of a diff is which existing function it should have called — which is a static-analysis job, not a human one. &lt;code&gt;pydepcall&lt;/code&gt;, the extractor the authors released, walks dependencies up to 100 levels deep, so the raw material is there. I have not built this yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Measure whether generated code calls what your repository already has, separately from whether it passes. They are different abilities, and the second one hides the first.&lt;/p&gt;




&lt;p&gt;Nam Le Hai, Dung Manh Nguyen, Nghi D. Q. Bui. "On the Impacts of Contexts on Repository-Level Code Generation." &lt;em&gt;Findings of the ACL: NAACL 2025&lt;/em&gt;, pages 1496-1524. Dataset and code: &lt;a href="https://github.com/FSoft-AI4Code/RepoExec" rel="noopener noreferrer"&gt;https://github.com/FSoft-AI4Code/RepoExec&lt;/a&gt;&lt;/p&gt;

</description>
      <category>llm</category>
      <category>codegeneration</category>
      <category>python</category>
      <category>techdebt</category>
    </item>
    <item>
      <title>99.7% Rejected in 84ms: Why I Stopped Making the Generator Smarter</title>
      <dc:creator>hidetzu</dc:creator>
      <pubDate>Sun, 06 Sep 2026 03:34:11 +0000</pubDate>
      <link>https://dev.to/hidetzu/997-rejected-in-84ms-why-i-stopped-making-the-generator-smarter-5em0</link>
      <guid>https://dev.to/hidetzu/997-rejected-in-84ms-why-i-stopped-making-the-generator-smarter-5em0</guid>
      <description>&lt;p&gt;I wrote a puzzle generator whose acceptance rate is &lt;strong&gt;0.26%&lt;/strong&gt;. It throws away 99.7% of everything it produces, and that is the design working as intended, not failing. Generating five valid puzzles takes 1,947 attempts and 84 milliseconds.&lt;/p&gt;

&lt;p&gt;The point is not the puzzles. The point is that the generator makes no correctness guarantee at all, and a verifier makes every one of them. Once you split those two responsibilities, "make the generator smarter" stops being the obvious optimisation — and that is exactly the position you are in when the generator is an LLM.&lt;/p&gt;

&lt;h2&gt;
  
  
  The loop
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;verigen&lt;/code&gt; is a Go CLI that produces cryptarithmetic puzzles — alphametics, the &lt;code&gt;SEND + MORE = MONEY&lt;/code&gt; genre, where each letter stands for a distinct digit and the sum has to hold. The known answer to that one is &lt;code&gt;9567 + 1085 = 10652&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkvjmpgi07mmcb9rdb321.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkvjmpgi07mmcb9rdb321.png" alt="Diagram 1" width="799" height="158"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There is one rule, and everything else follows from it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The generator guarantees nothing. Every guarantee lives in the verifier.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The generator throws plausible-looking letter combinations at the wall. The verifier does an exhaustive search and confirms two things: that a solution exists, and that it is unique. Anything that fails either check is discarded and the loop asks for another candidate.&lt;/p&gt;

&lt;p&gt;The loop itself knows nothing about cryptarithmetic. Implement a &lt;code&gt;Domain&lt;/code&gt; interface and any other puzzle rides the same loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the log actually says
&lt;/h2&gt;

&lt;p&gt;Five puzzles, seed 7:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;── Puzzle 2  [hard]
   HAIKU + BONSAI = KOKORO
   Answer: 96542 + 378165 = 474707
   (attempts before this seed landed: 624)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== generate/verify loop [alphametic] ===
seed=7  output=5 puzzles  total attempts=1947  elapsed=84ms
acceptance rate = 0.2568%  (average 389 generations per puzzle)
--- rejection reasons ---
  no unique solution                  770  (39.55%)
  no solution                         695  (35.70%)
  more than 10 distinct letters       477  (24.50%)
  ok                                    5  ( 0.26%)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nearly 40% of candidates have more than one valid solution. Another 36% have none. A quarter cannot possibly have one and are rejected before the search starts. Five survive.&lt;/p&gt;

&lt;p&gt;Filtering by difficulty makes it worse: asking for easy puzzles drops the acceptance rate to 0.03%, about 3,200 generations each. It still finishes instantly.&lt;/p&gt;

&lt;p&gt;I never tried to raise that number. Making the generator cleverer costs my time; making the verifier run 1,947 times costs 84ms. &lt;strong&gt;When generation is cheap, you can afford to be stupid on that side and pay for it in verification.&lt;/strong&gt; The log is what makes that trade-off intuitive rather than theoretical — I did not really believe it until I saw 99.7% of the work land in the discard pile without the wall-clock time moving.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the verifier has to guarantee
&lt;/h2&gt;

&lt;p&gt;The loop is only worth as much as the assertions the verifier can actually make. For alphametics, that is:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Guarantee&lt;/th&gt;
&lt;th&gt;How&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A solution exists&lt;/td&gt;
&lt;td&gt;Exhaustive search, column by column, carrying digits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The solution is unique&lt;/td&gt;
&lt;td&gt;Keep searching after the first hit; reject if a second exists&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;More than 10 distinct letters means no solution&lt;/td&gt;
&lt;td&gt;Pigeonhole. Rejected before the search runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No leading zero&lt;/td&gt;
&lt;td&gt;Constrain the first letter of every multi-character word&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three design decisions made that table hold up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I tested the verifier first.&lt;/strong&gt; If the verifier is wrong, every guarantee downstream is a lie, and nothing in the output looks wrong. So the first tests were puzzles with published answers — &lt;code&gt;SEND + MORE = MONEY&lt;/code&gt; → &lt;code&gt;9567 + 1085 = 10652&lt;/code&gt;, &lt;code&gt;CROSS + ROADS = DANGER&lt;/code&gt; → &lt;code&gt;96233 + 62513 = 158746&lt;/code&gt; — plus explicit cases for "no solution", "not unique", and the 11-letter rejection. The generator's tests came later. A broken generator lowers the acceptance rate; a broken verifier invalidates everything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pre-filtering only encodes facts, never heuristics.&lt;/strong&gt; More than ten distinct letters cannot map to ten digits, so no solution exists — pigeonhole, not a guess. That check alone accounts for 24.5% of rejections and removes the most expensive searches before they begin. Note where the optimisation went: not into a smarter generator, but into the verifier's front door, rejecting hopeless candidates fast.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Difficulty is labelled a proxy, out loud.&lt;/strong&gt; The difficulty score counts branch points during the search. That correlates with how much trial and error a human needs, probably — but I have not measured it against actual people, so the code and the docs both call it a proxy. Separating what you can assert from what you merely believe is not a nicety here. It is the discipline that keeps the word "verified" meaningful everywhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this already is, and where it isn't
&lt;/h2&gt;

&lt;p&gt;Swap the random generator for an LLM and the diagram is unchanged. That substitution is not a new idea in 2026 — in the major application areas it is already standard practice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code generation.&lt;/strong&gt; Formalised as loop engineering: hooks and agent harnesses that refuse to let a run finish until tests, types and lint pass. Cognition's 2025 annual review put the merge rate of Devin-generated PRs at 67%, up from 34% the year before — while noting that roughly a third of those PRs still need substantial rework. Passing the verifier is the floor, not the goal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SQL generation.&lt;/strong&gt; Snowflake's Cortex Analyst moved automatic optimisation of verified queries into preview in December 2025. Vanna AI ships self-correction from dry-run errors as a standard feature. &lt;code&gt;EXPLAIN&lt;/code&gt;, schema matching, and retry-on-execution-error are table stakes for text-to-SQL products.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Structured output.&lt;/strong&gt; This one has gone past verification entirely: XGrammar constrains decoding so schema-violating tokens are never emitted, and it is now a standard backend in vLLM, SGLang and TensorRT. OpenAI's Structured Outputs works the same way. "Parse the JSON, catch the error, retry" is close to obsolete.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proofs.&lt;/strong&gt; The most spectacular case, and the most instructive, because this is where the pattern was tested against its own alternative.&lt;/p&gt;

&lt;p&gt;AlphaProof, paired with AlphaGeometry 2, scored 28/42 at the 2024 IMO — silver-medal level, one point under the gold threshold. The mechanism is continuous with &lt;code&gt;verigen&lt;/code&gt;: generate candidate proofs, run them through Lean 4 as a mechanical verifier, repeat until one passes, at enormous scale. DeepSeek-Prover-V2 is the same shape. What exhaustive search does for "this puzzle has exactly one solution", Lean does for "this theorem holds".&lt;/p&gt;

&lt;p&gt;Then the following year, the verifier came out. Gemini Deep Think took gold at the 2025 IMO — 35/42, five of six problems — reasoning end-to-end in natural language, inside the human time limit, with no formal proof assistant anywhere in the loop, at roughly two orders of magnitude better inference efficiency than the 2024 approach. On the face of it that is a counterexample to everything I have argued.&lt;/p&gt;

&lt;p&gt;Look at what each one was solving. An olympiad problem is an exam question: a solution is known to exist, the search space is bounded by the syllabus, and a human grader decides at the end. Deep Think did not need a mechanical verifier because a mechanical verifier was never the binding constraint there. Now compare AlphaProof Nexus, which in May 2026 reported solving 9 of 353 open Erdős problems and 44 of 492 open OEIS conjectures (&lt;a href="https://arxiv.org/abs/2605.22763" rel="noopener noreferrer"&gt;arXiv:2605.22763&lt;/a&gt;) — including two open for 56 years. Nobody can grade that output. There is no answer key, and no human referee who can check the volume of candidates the system produces. Lean is not decoration in that setting; it is the only thing standing between "solved" and "claimed".&lt;/p&gt;

&lt;p&gt;So the split is not verifier versus no verifier. It is whether anything else can decide the question. Where a competent grader exists, a strict verifier is an optimisation you may be able to skip. Where none exists, it is the entire basis on which you are allowed to use the word "solved".&lt;/p&gt;

&lt;p&gt;The common precondition is that &lt;strong&gt;the verifier can decide the question mechanically&lt;/strong&gt;. Where it cannot — is this prose any good? — the whole design has nothing to offer.&lt;/p&gt;

&lt;p&gt;What is still open is the design of the verifiers themselves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Round-trip verification.&lt;/strong&gt; Transform the output back and check that it matches the input. Schema validation is everywhere; round-trip verification of semantic equivalence is not, and ETL and code migration between languages or frameworks are where it would pay.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A uniqueness-equivalent guarantee.&lt;/strong&gt; My verifier does not stop at "a solution exists"; it asserts the solution is unique. The LLM equivalent is not stopping at "the code runs" but checking that behaviour is uniquely determined by the spec — property-based testing that finds no counterexample gets close.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A quality filter.&lt;/strong&gt; &lt;code&gt;verigen&lt;/code&gt; cannot reject a puzzle that is solvable but boring, and nothing in the LLM stack reliably rejects code that passes its tests but is badly designed. The metric itself is the unsolved part.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this works
&lt;/h2&gt;

&lt;p&gt;Three conditions, in order of how often they are the one that breaks:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The verifier must be strict — and honest about its limits.&lt;/strong&gt; A verifier that appears to guarantee something it does not makes the entire loop a lie. Assert nothing you cannot assert. Labelling my difficulty score a proxy is the small version of this; treating "the tests pass" as "the code is correct" is the expensive version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generation must be cheap.&lt;/strong&gt; 1,947 attempts in 84ms is why a 0.26% acceptance rate is survivable. An LLM call is hundreds of milliseconds to seconds, so the same acceptance rate would be absurd. With an expensive generator you have to tune both sides: prompts that raise the hit rate &lt;em&gt;and&lt;/em&gt; a verifier strict enough to be worth the round trip.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verification must not cost more than generation.&lt;/strong&gt; Exhaustive search is expensive in principle and still fits inside those 84ms. Test suites and type checks do not always fit so comfortably. If verifying costs more than generating, the loop does not close.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd build next
&lt;/h2&gt;

&lt;p&gt;Classic puzzles were a deliberately easy setting: a domain where a verifier can make total guarantees by brute force, so I could think about verifier design without the verifier itself being the hard part. The loop is standard practice now. The interesting question has moved to what, exactly, your verifier is willing to assert.&lt;/p&gt;

&lt;p&gt;Next on the list: a quality filter, since right now I only guarantee that a puzzle is solvable and nothing about whether it is worth solving; more domains (polyomino tiling is done, knots and definite integrals are candidates); and checking that difficulty proxy against actual human solvers, so I can stop calling it a proxy.&lt;/p&gt;

&lt;p&gt;The repository: &lt;a href="https://github.com/hidetzu/verigen" rel="noopener noreferrer"&gt;https://github.com/hidetzu/verigen&lt;/a&gt;&lt;/p&gt;

</description>
      <category>llm</category>
      <category>verification</category>
      <category>architecture</category>
      <category>go</category>
    </item>
  </channel>
</rss>
